npm.io
0.4.0 • Published 2 weeks agoCLI

cc-session-io

Licence
MIT
Version
0.4.0
Deps
0
Size
128 kB
Vulns
0
Weekly
0
Stars
3

cc-session-io

Read, write, and create Claude Code session files.

Can create new session history files or read/write existing ones that will work with /resume in Claude Code. Useful for importing conversation history from another coding agent to continue it with claude.

No runtime dependencies. Not affiliated or supported by Anthropic, the makers of Claude Code.

Scope

Reads and writes user and assistant message records, with text, thinking, tool_use, tool_result and image content blocks, and attachment records — Claude Code's own injected context, such as an @file expansion. importMessages repairs tool_use/tool_result pairing on the way in. Sessions written this way resume in Claude Code.

Preserves every other record type Claude Code writes (queue-operation, last-prompt, summaries, and anything added by a future release) through UnknownRecord, so a read-modify-write leaves them untouched. Writing one means dangerousAppendRecord(), which appends whatever you hand it — the typed helpers only cover messages and attachments, because the other record types share almost none of their fields and synthesizing those would produce records Claude Code never writes.

Does not handle subagent transcripts: isSidechain is always written false, and Claude Code keeps sub-agent conversations in a separate subagents/ directory that this library neither creates nor manages. See also the Bun/Node hash mismatch for project paths over 200 characters.

Install

npm install cc-session-io

Requires Node >= 20.

Simple CLI Demo

# Create new session
cc-session create -p /my/project

# Add messages to session
cc-session add user "Hello!" -p /my/project -s <id>
cc-session add assistant "Hi, how can I help?" -p /my/project -s <id>

# Read conversation
cc-session read -p /my/project -s <id>

# List sessions
cc-session list -p /my/project

Options:

  • -p <path> - Project path (required)
  • -s <id> - Session ID (required for add/read)

Quick Start

Create a new session
import { createSession } from 'cc-session-io';

const session = createSession({ projectPath: '/path/to/project' });
session.addUserMessage('Refactor the auth module');
session.addAssistantMessage([{ type: 'text', text: 'I will refactor the auth module.' }]);
session.save();

// Resume it: claude --resume <session.sessionId>
Append to an existing session
import { openSession } from 'cc-session-io';

const session = openSession({
  sessionId: 'existing-uuid-here',
  projectPath: '/path/to/project',
});

// Existing messages are available
console.log(`${session.messages.length} messages loaded`);

// Append new messages — they chain onto the last existing record
session.addUserMessage('One more thing...');
session.addAssistantMessage([{ type: 'text', text: 'Sure, what is it?' }]);
session.save(); // appends to the existing JSONL file

API

createSession(opts): Session

Creates a new session. Generates a UUID session ID and resolves the JSONL path.

Option Type Default Description
projectPath string required Absolute path to the project. Determines where the JSONL file is written.
claudeDir string? ~/.claude Override the Claude config directory.
cwd string? projectPath Working directory written into records.
gitBranch string? "HEAD" Git branch name written into records.
version string? "2.1.83" Claude Code version to claim.
model string? "claude-sonnet-4-6" Default model for assistant messages.
openSession(opts): Session

Opens an existing session by ID for reading or appending.

Option Type Description
sessionId string UUID of the session.
projectPath string Absolute path to the project.
claudeDir string? Override the Claude config directory.
readSession(jsonlPath, projectPath?): Session

Reads a session directly from a JSONL file path.

Session
Properties
Property Type Description
sessionId string UUID of the session.
projectPath string Project path.
jsonlPath string Absolute path to the JSONL file.
records JsonlRecord[] All JSONL records (including non-message types).
messages (UserRecord | AssistantRecord)[] Only user and assistant records.
attachments AttachmentRecord[] Only attachment records.
session.addUserMessage(content): string

Adds a user message. content is either a string or an array of user content blocks (text, image, tool_result), which are written unchanged. Throws on an empty array, which Anthropic rejects. Returns the record's UUID.

session.dangerousAppendRecord(record, opts?): void

Appends a record verbatim. The escape hatch for record types this library does not model, and for carrying records from one session into another — with one exception: use addAttachment for attachments.

Dangerous because nothing is synthesized or validated beyond sessionId and the parent link — the shape is whatever you pass, and Claude Code will read it back. sessionId is overwritten with this session's, since a record claiming another session is never what a caller wants. The record is appended verbatim and does not advance the chain, so the next message parents to whatever came before it. That is right for chain-external records like queue-operation, but wrong for attachments — Claude Code threads those through the chain, so carry them with addAttachment instead.

parentUuid is the one thing checked: a dangling parent makes Claude Code resume with empty context and answer confidently from nothing, and it does not announce itself, so it throws instead.

// Append a record type this library does not model
target.dangerousAppendRecord(queueOperationRecord, { parentUuid: lastMessageUuid });
session.addAttachment(attachment, opts?): string

Adds an attachment record — Claude Code's injected context, written as its own record rather than as part of a message. The payload shape varies by attachment.type; only file and edited_text_file carry content a consumer cannot regenerate, and Claude Code rewrites the rest (skill_listing, task_reminder, agent_listing_delta, …) every turn.

Attachments are links in the uuid chain, not leaves hanging off it — the record that follows one parents to the attachment — so this advances the chain exactly as the message methods do. parentUuid defaults to whatever the session would currently chain from; pass it explicitly when carrying a record over from another session, where the message it belongs to has been given a new UUID.

Because they sit in the chain, inserting one between existing records would mean re-parenting the record that follows, which this method does not do — it appends. To place attachments within a conversation, pass them to importMessages instead:

session.importMessages(messages, {
  attachments: [{ afterIndex: 0, attachment: fileAttachment }],
});

afterIndex indexes the array you passed. repairToolPairing runs inside the import and can insert or drop messages, so positions are mapped through the exported repairWithOrigin(). An attachment whose message the repair dropped is not emitted, nor is one with an out-of-range index (which logs a warning), so check session.attachments when it matters.

const userUuid = session.addUserMessage('review @build.js');
session.addAttachment(
  {
    type: 'file',
    filename: '/project/build.js',
    content: { type: 'text', file: { filePath: '/project/build.js', content: source } },
  },
  { parentUuid: userUuid },
);
session.addAssistantMessage(content, opts?): string

Adds an assistant message with the given content blocks. Returns the record's UUID. Content blocks are written as-is — any tool_use blocks you provide keep their original id values. (This matters when replaying real API responses; addToolCalls() generates IDs for you, but addAssistantMessage() does not.)

session.addAssistantMessage([{ type: 'text', text: 'Hello!' }]);

session.addAssistantMessage(
  [{ type: 'text', text: 'Done.' }],
  { model: 'claude-opus-4-6' },
);

// Pass through real tool_use blocks with their original IDs
session.addAssistantMessage([
  { type: 'tool_use', id: 'toolu_01ABC...', name: 'Read', input: { file_path: '/foo' } },
]);
Option Type Default
model string? Session default model
stopReason string? Auto-detected: "tool_use" if content has tool_use blocks, "end_turn" otherwise
session.addToolResults(results): string

Adds a user message containing tool results. Each toolUseId must match the id of a tool_use block from the preceding assistant message — this is how Claude Code pairs requests with responses.

// After an assistant message with tool_use blocks:
session.addToolResults([
  { toolUseId: 'toolu_abc', content: 'file contents here' },
  { toolUseId: 'toolu_def', content: 'command output', isError: true },
]);
session.addToolCalls(calls, opts?): void

Convenience method that creates the full tool call round-trip: assistant tool_use message, user tool_result message, and optionally a final assistant response.

// Single tool call with response
session.addToolCalls(
  [{ name: 'Read', input: { file_path: '/foo.ts' }, result: 'file contents' }],
  { response: [{ type: 'text', text: 'I read the file.' }] },
);

// Multiple parallel tool calls
session.addToolCalls([
  { name: 'Read', input: { file_path: '/foo.ts' }, result: 'contents of foo' },
  { name: 'Bash', input: { command: 'ls' }, result: 'README.md\nsrc/' },
], { response: [{ type: 'text', text: 'I read the file and listed the directory.' }] });

// Tool call without a follow-up response (leaves the turn open)
session.addToolCalls([
  { name: 'Bash', input: { command: 'npm test' }, result: 'all tests passed' },
]);
ToolCallSpec field Type Description
name string Tool name (e.g. "Read", "Bash", "Edit")
input unknown Tool input parameters
result string | ContentBlock[] Tool output
isError boolean? Whether the tool call errored
session.importMessages(messages): void

Bulk import an array of Anthropic API-shaped messages. Dispatches each to the right internal method based on role and content type. Useful when replaying API responses or syncing from another provider.

session.importMessages([
  { role: 'user', content: 'Read the config file' },
  { role: 'assistant', content: [
    { type: 'tool_use', id: 'toolu_abc', name: 'Read', input: { file_path: '/config.json' } },
  ]},
  { role: 'user', content: [
    { type: 'tool_result', tool_use_id: 'toolu_abc', content: '{"port": 3000}' },
  ]},
  { role: 'assistant', content: [{ type: 'text', text: 'The config sets port to 3000.' }] },
]);

Dispatch rules:

  • assistantaddAssistantMessage() (string content wrapped in a text block)
  • user string → addUserMessage()
  • user array with tool_result blocks → addToolResults(), and any remaining blocks follow as a separate addUserMessage() record
  • user array without tool_result blocks → addUserMessage() (blocks kept as-is)
session.save(): void

Writes all pending records to disk. Creates the JSONL file and parent directories if needed. Appends if the file already exists.

Session cleanup
session.clear(): void

Resets the session to empty state: wipes in-memory records, deletes the on-disk .jsonl file, and removes the companion directory (Claude Code v2.1.x writes subagents/ and tool-results/ there). The next save() writes a fresh file.

session.clear();
session.addUserMessage('Start over');
session.save(); // writes a new file at the original jsonlPath
deleteSession(sessionId, projectPath, claudeDir?): void

Deletes a session's .jsonl file and companion directory without opening it. No-op if the file doesn't exist.

import { deleteSession } from 'cc-session-io';

deleteSession('uuid-here', '/path/to/project');
Low-level JSONL utilities
import { parseJsonl, parseJsonlFile, serializeRecord, serializeJsonl } from 'cc-session-io';

const records = parseJsonlFile('/path/to/session.jsonl');
const jsonlString = serializeJsonl(records);
Path utilities
import { projectPathToHash, getProjectDir, getSessionPath, getClaudeDir, normalizeProjectPath } from 'cc-session-io';

projectPathToHash('/Users/me/project');
// => '-Users-me-project'

getSessionPath('uuid-here', '/Users/me/project');
// => '/Users/me/.claude/projects/-Users-me-project/uuid-here.jsonl'

getClaudeDir();           // ~/.claude, or CLAUDE_CONFIG_DIR if set
normalizeProjectPath('/path/to/project');  // realpathSync + NFC normalization

Content Block Format

All content blocks use the Anthropic API format. If you're coming from another agent framework, note the naming:

This library (Anthropic API) Other conventions
tool_use toolCall, function_call
tool_result toolResult, function_response
tool_use_id toolCallId
stop_reason: "tool_use" finish_reason: "function_call"

How It Works

Claude Code stores sessions as JSONL files in ~/.claude/projects/-<path-hash>/. Each line is a JSON record. User and assistant messages form a linked list via uuid/parentUuid fields.

This library writes JSONL files that match the real Claude Code format. On resume, Claude Code replays the stored messages to rebuild context. No SQLite database or session index is required — the JSONL file alone is sufficient.

Path resolution

To place session files where Claude Code will actually find them, this library:

  • Resolves projectPath via realpathSync + NFC normalization before hashing, matching what Claude Code does in its bootstrap. A caller passing process.cwd() from a shell that entered via a symlink (common on macOS: /tmp, /var, user-symlinked dirs) still writes to the correct hash dir. Falls back to NFC of the raw path when realpath fails.
  • Honors CLAUDE_CONFIG_DIR, same as Claude Code. getClaudeDir() returns it when set, otherwise ~/.claude.
  • normalizeProjectPath() is exported for callers that want to apply the same normalization.
Known limitation: Bun/Node hash mismatch for long paths

For projectPath encodings longer than 200 characters, Claude Code appends a hash suffix. The hash algorithm differs by runtime: Bun uses Bun.hash (wyhash), Node uses djb2. The shipped Claude Code CLI typically runs under Bun, so a session written by a Node consumer of this library (which always uses djb2) lands in a different directory than the Bun-executed CLI looks in, producing "No conversation found with session ID" on resume.

Affected only when the encoded path exceeds 200 chars — roughly, a project path with deeply nested directories totalling over 200 characters of alphanumerics. Rare in practice but worth knowing.

Workarounds:

  • Keep project paths shorter than ~200 characters (trivially satisfied by most real workspaces).
  • Run the consumer under Bun, so Bun.hash is available and matches the CLI.
  • Internally, session lookup applies a prefix-match fallback to find dirs produced by the other hash, so listing and resuming existing sessions works either way. Only fresh writes hit the mismatch.
Minimum viable record

The library writes full-fidelity records with all fields, but Claude Code only requires these for resume:

{"type":"user","uuid":"<uuid>","parentUuid":null,"sessionId":"<uuid>","timestamp":"<ISO 8601>","message":{"role":"user","content":"hello"}}
{"type":"assistant","uuid":"<uuid>","parentUuid":"<user-uuid>","sessionId":"<uuid>","timestamp":"<ISO 8601>","message":{"id":"msg_...","type":"message","role":"assistant","model":"claude-sonnet-4-6","content":[{"type":"text","text":"hi"}],"stop_reason":"end_turn","stop_sequence":null,"usage":{"input_tokens":0,"output_tokens":0}}}

Everything else (cwd, version, isSidechain, slug, entrypoint, gitBranch, userType) is optional. See docs/claude-code-sessions.md for full format documentation and research findings.

Testing

npm test          # unit + integration tests
npm run smoke     # end-to-end: creates a session, resumes with claude CLI

Keywords