# 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](#known-limitation-bunnode-hash-mismatch-for-long-paths) for project paths
over 200 characters.

## Install

```
npm install cc-session-io
```

Requires Node >= 20.

## Simple CLI Demo

```bash
# 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

```typescript
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

```typescript
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.

```typescript
// 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:

```typescript
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.

```typescript
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.)

```typescript
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.

```typescript
// 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.

```typescript
// 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.

```typescript
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:
- `assistant` → `addAssistantMessage()` (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.

```typescript
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.

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

deleteSession('uuid-here', '/path/to/project');
```

### Low-level JSONL utilities

```typescript
import { parseJsonl, parseJsonlFile, serializeRecord, serializeJsonl } from 'cc-session-io';

const records = parseJsonlFile('/path/to/session.jsonl');
const jsonlString = serializeJsonl(records);
```

### Path utilities

```typescript
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:

```jsonl
{"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`](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
```
