Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 24 additions & 9 deletions adapters/claude/README.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
# Claude Code Adapter


The 0.5.11 `legacy-bridge` release retains the published 0.5.10 capture
Implementation while the CLI gains target-owned automatic updates.
The Claude Adapter keeps Canonical/publication v4 and source profile
`claude.jsonl.active-path.2`. Ordinary native-tool identity and file-activity capture are
planned for the subsequent `target-owned` release. See
The 0.5.12 `target-owned` Adapter restores native tool identity and recorded
file activity through Canonical/publication v5, source profile
`claude.jsonl.active-path.4` and projection revision 7. The 0.5.11
`legacy-bridge` retains its genuine v4/active-path.2 capture for older clients.
The verified target CLI owns preparation of this v5 Adapter; see
[ADR-0126](../../docs/architecture/adr/0126-target-owned-managed-updates.md).
See the [0.5.12 release notes](../../docs/releases/v0.5.12.md) for publication,
deployment and acceptance evidence.

Capture Project-scoped Claude Code JSONL through ATape's normal Collector,
shared ACP profile, Server and conversation reader.

```sh
pnpm --filter @atape/adapter-claude build
Expand All @@ -32,8 +36,8 @@ of the earlier implementation.

The factory exposes only `atape.source-capture.v2`, with explicit
`atape.legacy-migration.v1` support for acknowledged earlier checkpoints. A compatible
Host and publication-enabled Server with v4 targets and legacy adoption are required.
The Host must explicitly advertise Canonical v4 when opening a source; older
Host and publication-enabled Server with v5 targets and legacy adoption are required.
The Host must explicitly advertise Canonical v5 when opening a source; older
Hosts are rejected before source reads instead of dropping normalized identities.
This checkout requirement is separate from any published Adapter version.

Expand All @@ -50,10 +54,21 @@ captured child can retain prior stored history with a diagnostic while the root
advances. Raw-enabled capture also archives available historical child sources
proved by admitted root receipts outside the selected path.

Explicit MCP and `Skill` tool invocations preserve bounded identities for
Explicit native tool names, MCP and `Skill` tool invocations preserve bounded identities for
[Session Analytics](../../docs/session-analytics.md). Skill file reads and command
expansions are not counted as Skill tools. The Server does not infer identity
from labels or Raw, and upload redaction can explicitly clear a sensitive identity.
The effective source profile is `claude.jsonl.active-path.4`; existing v1 source
proof checkpoints remain readable. The Host replaces available older Canonical
projections once and retains existing Raw receipts. Replay, new identities and file activity
require available source bytes; a lost source does not erase captured history.

The v5 projection adds explicit file activity: `Read`
`file_path`, `Write`/`Edit`/`MultiEdit` `file_path`, and `Grep`/`Glob` `path` scopes.
Paths retain their original spelling; searches do not claim matching files.
Missing, malformed, oversized or hidden paths remain unknown. Shell commands,
MCP inputs and search patterns are not parsed for paths. Upload redaction clears
sensitive copied facts. See the [Adapter guide](../../docs/adapters/claude.md#explicit-file-activity).

Discovery uses `~/.claude/projects/*/*.jsonl`. `ATAPE_CLAUDE_HOME` selects an absolute
alternate configuration directory; `ATAPE_CLAUDE_SESSION_FILE` selects an absolute
Expand Down
4 changes: 2 additions & 2 deletions adapters/claude/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@atape/adapter-claude",
"version": "0.5.11",
"version": "0.5.12",
"description": "ATape Adapter for project-scoped Claude Code local sessions",
"license": "MIT",
"type": "module",
Expand Down Expand Up @@ -50,7 +50,7 @@
"gitAttribution": "atape.git-attribution.v1",
"rawCapturePolicy": "atape.raw-capture.v1",
"sourceCapture": "atape.source-capture.v2",
"publicationTargetProfile": "atape.publication-target.v4",
"publicationTargetProfile": "atape.publication-target.v5",
"legacyMigration": "atape.legacy-migration.v1",
"adapterId": "claude",
"displayName": "Claude Code",
Expand Down
51 changes: 37 additions & 14 deletions adapters/claude/src/claudeArchive.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { AcpSessionUpdate, AdapterEvent, AdapterOpenContext, AdapterUsage,
SourceCaptureFrame, SourceCaptureLimits, SourceDiscoveryPage, SourceOpenRequestV2, SourceCaptureViewV2, SourceLegacyMigrationRequest } from "@atape/domain"
import { Effect, Schema } from "effect"
import { AcpToolIdentity, CanonicalProfileVersion4, GitAttributionVersion, isBoundedToolValue, MaxSourceFailures, ToolIdentityMetaKey, type AdapterSourceFailure } from "@atape/domain"
import { AcpFileActivity, AcpToolIdentity, CanonicalProfileVersion5, FileActivityMetaKey, GitAttributionVersion, isBoundedToolValue, MaxSourceFailures, ToolIdentityMetaKey, type AdapterSourceFailure } from "@atape/domain"
import { createHash } from "node:crypto"
import { constants } from "node:fs"
import { lstat, open, readdir, realpath } from "node:fs/promises"
Expand All @@ -15,7 +15,7 @@ const MaxDiscoveryEntries = 10_000
const MaxHeaderBytes = 64 * 1024 * 1024
const MaxCursorBytes = 1024 * 1024
const MaxDecodedCursorBytes = 16 * 1024 * 1024
const ProjectionRevision = 5
const ProjectionRevision = 7
const RecordSchema = Schema.Record(Schema.String, Schema.Unknown)
const decodeRecord = Schema.decodeUnknownSync(RecordSchema)
const CompactionSchema = Schema.Struct({
Expand Down Expand Up @@ -441,7 +441,7 @@ async function validatePendingProjection(handle: Awaited<ReturnType<typeof open>
fail("cursor", "Claude checkpoint has no admitted pending conversation record.")
const calls = new Map(context.calls)
const projected = projectRecord(record, context.order, line.end, sourceObjectId, calls,
cursor.projectionRevision === ProjectionRevision ? ProjectionRevision : 4)
cursor.projectionRevision === 5 ? 5 : cursor.projectionRevision === 6 ? 6 : cursor.projectionRevision === ProjectionRevision ? ProjectionRevision : 4)
bindChild(record, calls, delegated, children, cursor.sessionId)
if (skip > projected.events.length) fail("cursor", "Claude record checkpoint exceeds its event count.")
return
Expand Down Expand Up @@ -565,7 +565,7 @@ function rootTitle(root: RecordValue): string {
}

function projectRecord(record: RecordValue, order: number, revision: number, sourceObjectId: string,
calls: Map<string, SourceCall>, projectionRevision: 4 | 5 = ProjectionRevision, includeIdentity = false): { events: Array<AdapterEvent & { readonly occurredAt: string }>; partial: boolean } {
calls: Map<string, SourceCall>, projectionRevision: 4 | 5 | 6 | 7 = ProjectionRevision, includeIdentity = false): { events: Array<AdapterEvent & { readonly occurredAt: string }>; partial: boolean } {
const events: Array<AdapterEvent & { readonly occurredAt: string }> = []
let partial = false
if (!record.uuid || record.isMeta === true || record.type !== "user" && record.type !== "assistant") return { events, partial }
Expand All @@ -583,7 +583,7 @@ function projectRecord(record: RecordValue, order: number, revision: number, sou
// Source command envelopes need a provider mapping, not fake user prose.
if (record.type === "user" && userCommandText(block.text)) { partial = true; continue }
update = { sessionUpdate: record.type === "user" ? "user_message_chunk" : "agent_message_chunk", content: { type: "text", text: block.text } }
} else if (projectionRevision === ProjectionRevision && block?.type === "thinking" && record.type === "assistant" &&
} else if (projectionRevision >= 5 && block?.type === "thinking" && record.type === "assistant" &&
typeof block.thinking === "string" && block.thinking.length > 0) {
update = { sessionUpdate: "agent_thought_chunk", content: { type: "text", text: block.thinking } }
} else if (block?.type === "tool_use" && record.type === "assistant" && typeof block.id === "string" && typeof block.name === "string") {
Expand All @@ -593,14 +593,15 @@ function projectRecord(record: RecordValue, order: number, revision: number, sou
// A copied Skill name needs its original field at the redaction
// boundary. An omitted oversized input cannot justify a new copy.
const identity = includeIdentity && (block.name !== "Skill" || admittedInput) ? toolIdentity(block) : undefined
update = { sessionUpdate: "tool_call", toolCallId: block.id, title: block.name, status: "pending", kind: toolKind(block.name),
const activity = includeIdentity ? fileActivity(block, admittedInput) : undefined
update = { sessionUpdate: "tool_call", toolCallId: block.id, title: includeIdentity ? toolTitle(block.name) : block.name, status: "pending", kind: toolKind(block.name),
...(admittedInput ? { rawInput: block.input } : {}),
...(identity ? { _meta: { [ToolIdentityMetaKey]: identity } } : {}) }
...(identity ? { _meta: { [ToolIdentityMetaKey]: identity, ...(activity === undefined ? {} : { [FileActivityMetaKey]: activity }) } } : {}) }
partial = true; fidelity = "partial"
} else if (block?.type === "tool_result" && record.type === "user" && typeof block.tool_use_id === "string") {
const call = calls.get(block.tool_use_id)
if (!call || record.sourceToolAssistantUUID !== undefined && record.sourceToolAssistantUUID !== call.uuid) fail("unsupported", "Claude tool result has no unambiguous call.")
update = { sessionUpdate: "tool_call_update", toolCallId: block.tool_use_id, title: call.name, status: block.is_error === true ? "failed" : "completed", kind: toolKind(call.name),
update = { sessionUpdate: "tool_call_update", toolCallId: block.tool_use_id, title: includeIdentity ? toolTitle(call.name) : call.name, status: block.is_error === true ? "failed" : "completed", kind: toolKind(call.name),
...(isBoundedToolValue(block.content) ? { rawOutput: block.content } : {}) }
partial = true; fidelity = "partial"
} else if (block?.type === "thinking" && block.thinking === "") {
Expand Down Expand Up @@ -662,7 +663,7 @@ function decodeCursor(value: string | null): Cursor | undefined {
if (Buffer.byteLength(value) > MaxDecodedCursorBytes) throw new Error()
const c = Schema.decodeUnknownSync(CursorSchema)(JSON.parse(value))
if (!c.sessionId || !isAbsolute(c.origin) || !Number.isSafeInteger(c.bytes) || c.bytes < 0 || !/^[a-f0-9]{64}$/.test(c.digest)) throw new Error()
if (c.projectionRevision !== undefined && ![2, 3, 4, ProjectionRevision].includes(c.projectionRevision)) throw new Error()
if (c.projectionRevision !== undefined && ![2, 3, 4, 5, 6, ProjectionRevision].includes(c.projectionRevision)) throw new Error()
if (c.familyRevision !== undefined && (!Number.isSafeInteger(c.familyRevision) || c.familyRevision < 1)) throw new Error()
if (c.familyObservedAt !== undefined && timestamp(c.familyObservedAt) !== c.familyObservedAt) throw new Error()
if (c.children) {
Expand Down Expand Up @@ -822,15 +823,37 @@ async function belongsToProject(archive: Archive, root: RecordValue, signal: Abo
const toolKind = (name: string): "read" | "edit" | "execute" | "search" | "other" =>
name === "Read" ? "read" : ["Write", "Edit", "MultiEdit"].includes(name) ? "edit" : name === "Bash" ? "execute" : ["Grep", "Glob"].includes(name) ? "search" : "other"

const isFileActivity = Schema.is(AcpFileActivity)
function fileActivity(block: RecordValue, admittedInput: boolean): AcpFileActivity | null | undefined {
const operation = toolKind(block.name as string)
if (operation !== "read" && operation !== "edit" && operation !== "search") return undefined
const input = object(block.input)
if (!admittedInput || !input) return null
// Search paths are explicit scopes, never the contents of a pattern or result.
const facts = [{ operation, path: operation === "search" ? input.path : input.file_path }]
return isFileActivity(facts) ? facts : null
}

// Only explicit native tool invocations establish an identity. Reading a Skill
// file, mentioning a tool, or retaining an injected command envelope does not.
function toolTitle(name: string): string {
if (name.trim() === "") return "Claude tool"
const bytes = Buffer.from(name)
let end = Math.min(bytes.length, 500)
while (end < bytes.length && (bytes[end]! & 0xc0) === 0x80) end--
return bytes.subarray(0, end).toString("utf8")
}
const isToolIdentity = Schema.is(AcpToolIdentity)
function toolIdentity(block: RecordValue): AcpToolIdentity | undefined {
if (block.name === "Skill") {
const identity = { type: "skill", name: object(block.input)?.skill }
return isToolIdentity(identity) ? identity : undefined
}
if (typeof block.name !== "string" || !block.name.startsWith("mcp__")) return undefined
if (typeof block.name !== "string") return undefined
if (!block.name.startsWith("mcp__")) {
const identity = { type: "tool", name: block.name }
return isToolIdentity(identity) ? identity : undefined
}
const separator = block.name.indexOf("__", 5)
if (separator < 0) return undefined
const identity = { type: "mcp", server: block.name.slice(5, separator), name: block.name.slice(separator + 2) }
Expand All @@ -846,7 +869,7 @@ type CaptureStream = {
file: string; threadId: string; root: RecordValue; refs: RecordRef[]; nodes: Map<string, CaptureNode>; selected: Set<number>
latestUsage: Map<string, number>; children: Child[]; proof: SourceProof; events: number; usage: number; observedAt: string
}
const captureProfile = "claude.jsonl.active-path.2"
const captureProfile = "claude.jsonl.active-path.4"
const emptyNormalization = (): Normalization => ({ records: new Map(), calls: new Map(), leaf: null, order: 0, continuation: undefined, response: undefined })
const captureFailure = (cause: unknown) => cause instanceof ClaudeArchiveError ? cause : new ClaudeArchiveError({ reason: "io", message: "Could not read the selected Claude source view." })
function captureCheckpoint(value: string | undefined): CaptureCheckpoint | undefined {
Expand Down Expand Up @@ -1073,8 +1096,8 @@ async function indexCaptureStream(file: string, rootIdentity: RecordValue | unde
* publication. Provider branch selection and source integrity stay here. */
export const openClaudeCapture = (archive: Archive, request: SourceOpenRequestV2): Effect.Effect<SourceCaptureViewV2, ClaudeArchiveError> => Effect.tryPromise({
try: async () => {
if (!request.projection.canonicalProfiles?.includes(CanonicalProfileVersion4))
fail("unsupported", "Upgrade the ATape CLI to retain normalized tool identities with Canonical v4.")
if (!request.projection.canonicalProfiles?.includes(CanonicalProfileVersion5))
fail("unsupported", "Upgrade the ATape CLI to retain normalized tool identities with Canonical v5.")
let readSignal: AbortSignal | undefined
const started = performance.now(), check = () => {
request.signal.throwIfAborted()
Expand Down Expand Up @@ -1147,7 +1170,7 @@ export const openClaudeCapture = (archive: Archive, request: SourceOpenRequestV2
const sourceCheckpoint = JSON.stringify({ v: 1, sessionId: origin.sourceId, origin: origin.cwd, rootUuid: origin.originKey, streams: [...proofs.values()] } satisfies CaptureCheckpoint)
if (Buffer.byteLength(sourceCheckpoint) > MaxCursorBytes) fail("limit", "Claude source checkpoint exceeds its metadata capacity.")
const observedAt = streams.filter(stream => linkedThreads.has(stream.threadId)).map(stream => stream.observedAt).sort().at(-1)!
const header = { profile: captureProfile, canonicalProfileVersion: CanonicalProfileVersion4, origin, sourceCheckpoint,
const header = { profile: captureProfile, canonicalProfileVersion: CanonicalProfileVersion5, origin, sourceCheckpoint,
session: { sourceSessionId: origin.sourceId, title: rootTitle(root.root), summary: "Claude Code conversation", insight: "",
actor: { name: "User", harness: "Claude Code" }, branch: string(root.root.gitBranch) ?? "", status: "active" as const,
captureStatus: "partial" as const, updatedAt: observedAt, reportedEventCount: eventCount },
Expand Down
4 changes: 2 additions & 2 deletions adapters/claude/src/claudeBackground.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { CanonicalProfileVersion4, AdapterCollectionLimits, SourceCaptureHeaderV2, SourceCapturePage, type AdapterOpenContext, type SourceCaptureFrame, type SourceOpenRequestV2 } from "@atape/domain"
import { CanonicalProfileVersion5, AdapterCollectionLimits, SourceCaptureHeaderV2, SourceCapturePage, type AdapterOpenContext, type SourceCaptureFrame, type SourceOpenRequestV2 } from "@atape/domain"
import { Effect, Schema } from "effect"
import { appendFile, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"
import { tmpdir } from "node:os"
Expand All @@ -16,7 +16,7 @@ const nativeRoot = await readFile(new URL("root.jsonl", folder), "utf8")
const nativeChild = await readFile(new URL("child.jsonl", folder), "utf8")
const childId = `claude-agent:${proof.agentId}`, launchId = `${proof.call.receiptUuid}:0`
const limits = { rowBytes: 16 * 1024 * 1024, pageBytes: 32 * 1024 * 1024, pageRows: 100, records: 10000, threads: 100, durationMs: 300000 }
const projection = { events: 100000, usage: 100000, pageItems: 1, pageBytes: 32 * 1024 * 1024, canonicalProfiles: [CanonicalProfileVersion4] }
const projection = { events: 100000, usage: 100000, pageItems: 1, pageBytes: 32 * 1024 * 1024, canonicalProfiles: [CanonicalProfileVersion5] }
const encode = (rows: Row[]) => rows.map(row => JSON.stringify(row) + "\n").join("")
const decode = (text: string): Row[] => text.trimEnd().split("\n").map(line => JSON.parse(line))
let directory: string, file: string, childFile: string, context: AdapterOpenContext & { signal: AbortSignal }
Expand Down
4 changes: 2 additions & 2 deletions adapters/claude/src/claudeBackgroundCompatibility.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { CanonicalProfileVersion4, SourceCaptureHeaderV2, SourceCapturePage, type AdapterOpenContext, type SourceCaptureFrame,
import { CanonicalProfileVersion5, SourceCaptureHeaderV2, SourceCapturePage, type AdapterOpenContext, type SourceCaptureFrame,
type SourceOpenRequestV2 } from "@atape/domain"
import { Schema } from "effect"
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"
Expand All @@ -13,7 +13,7 @@ const folder = new URL("../fixtures/native-background-child-2.1.263/", import.me
const proof = JSON.parse(await readFile(new URL("provenance.json", folder), "utf8")) as Row
const root = await readFile(new URL("root.jsonl", folder), "utf8"), child = await readFile(new URL("child.jsonl", folder), "utf8")
const limits = { rowBytes: 16 * 1024 * 1024, pageBytes: 32 * 1024 * 1024, pageRows: 100, records: 10000, threads: 100, durationMs: 300000 }
const projection = { events: 100000, usage: 100000, pageItems: 1, pageBytes: 32 * 1024 * 1024, canonicalProfiles: [CanonicalProfileVersion4] }
const projection = { events: 100000, usage: 100000, pageItems: 1, pageBytes: 32 * 1024 * 1024, canonicalProfiles: [CanonicalProfileVersion5] }
const childId = `claude-agent:${proof.agentId}`, notificationId = `${proof.notification.uuid}:0`, launchId = `${proof.call.receiptUuid}:0`
let historical: Awaited<ReturnType<typeof historicalSourceCaptureFactory>>
let directory: string, file: string, childFile: string, context: AdapterOpenContext & { signal: AbortSignal }
Expand Down
Loading
Loading