Skip to content
Open
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
16 changes: 16 additions & 0 deletions .changeset/session-sync-claude-config-dir.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
"@prover-coder-ai/docker-git-session-sync": patch
---

Back up Claude Code sessions from the resolved `CLAUDE_CONFIG_DIR` (issue #422).

docker-git points Claude Code at a custom `CLAUDE_CONFIG_DIR`
(`~/.docker-git/.orch/auth/claude/<label>`), so Claude writes chat transcripts to
`$CLAUDE_CONFIG_DIR/projects` instead of `~/.claude/projects`. The session backup
only scanned the home-relative paths, so the `.claude` folder in the
`docker-git-sessions` backup repo stayed empty.

The backup now resolves each session root from its agent environment override
(`CLAUDE_CONFIG_DIR` for Claude, `CODEX_HOME` for Codex) and falls back to the
home-relative directory when the override is unset, keeping the logical
`.claude/projects` / `.codex/sessions` names stable in the backup repo.
9 changes: 9 additions & 0 deletions packages/app/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
# @prover-coder-ai/docker-git

## 1.3.9

### Patch Changes

- chore: automated version bump

- Updated dependencies []:
- @prover-coder-ai/docker-git-session-sync@1.0.65

## 1.3.8

### Patch Changes
Expand Down
2 changes: 1 addition & 1 deletion packages/app/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@prover-coder-ai/docker-git",
"version": "1.3.8",
"version": "1.3.9",
"description": "docker-git Bun and Gridland CLI plus browser frontend",
"main": "dist/src/docker-git/main.js",
"bin": {
Expand Down
6 changes: 6 additions & 0 deletions packages/docker-git-session-sync/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# @prover-coder-ai/docker-git-session-sync

## 1.0.65

### Patch Changes

- chore: automated version bump

## 1.0.64

### Patch Changes
Expand Down
2 changes: 1 addition & 1 deletion packages/docker-git-session-sync/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@prover-coder-ai/docker-git-session-sync",
"version": "1.0.64",
"version": "1.0.65",
"description": "Standalone docker-git AI agent session synchronization tool",
"main": "dist/docker-git-session-sync.js",
"bin": {
Expand Down
15 changes: 12 additions & 3 deletions packages/docker-git-session-sync/src/backup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
isPathWithinParent,
isChatTranscriptPath,
sessionDirNames,
sessionRootCandidatePaths,
sessionRootSpecs,
sessionWalkIgnoreDirNames,
shouldIgnoreSessionPath,
sortSessionFiles,
Expand Down Expand Up @@ -229,9 +231,16 @@

const getAllowedSessionRoots = (): ReadonlyArray<SessionDir> => {
const homeDir = os.homedir()
return sessionDirNames
.map((dirName) => ({ name: dirName, path: path.join(homeDir, dirName) }))
.filter((entry) => fs.existsSync(entry.path))
const roots: Array<SessionDir> = []
for (const spec of sessionRootSpecs) {
const existing = sessionRootCandidatePaths(spec, homeDir, process.env).find((candidatePath) =>
fs.existsSync(candidatePath)
)
if (existing !== undefined) {
roots.push({ name: spec.name, path: existing })
}
}
return roots
}

const resolveAllowedSessionDir = (
Expand Down Expand Up @@ -311,7 +320,7 @@
if (!isChatTranscriptPath(logicalName)) {
logVerbose(verbose, output, `Skipping non-chat file: ${logicalName}`)
continue
}

Check warning on line 323 in packages/docker-git-session-sync/src/backup.ts

View workflow job for this annotation

GitHub Actions / Lint Effect-TS

Effect migration blocker: use Effect.try / Effect.catch* instead of try/catch
files.push({ logicalName, sourcePath: fullPath, size: stats.size })
logVerbose(verbose, output, `Collected file: ${logicalName} (${stats.size} bytes)`)
} catch (error) {
Expand Down Expand Up @@ -493,7 +502,7 @@
logVerbose(verbose, output, `Posting git status comment to PR #${resolved.prContext.prNumber}`)
const comment = createPrComment(
resolved.prContext.repo,
resolved.prContext.prNumber,

Check warning on line 505 in packages/docker-git-session-sync/src/backup.ts

View workflow job for this annotation

GitHub Actions / Lint Effect-TS

Effect migration blocker: use Effect.try / Effect.catch* instead of try/catch
buildCommentBody({ source: resolved.source, upload: { state: "queued" }, gitStatus: resolved.gitStatus }),
ghEnv
)
Expand Down Expand Up @@ -594,7 +603,7 @@
output.out(`[session-backup] skipped: no new or changed chat transcripts (${summary.fileCount} files, ${formatBytes(summary.totalBytes)}; RTK ${formatTokenReduction(tokenReduction)})`)
printGitStatus(output, context.gitStatus)
logVerbose(verbose, output, `[session-backup] No backup repo changes for ${backupRepo.fullName}:${context.snapshotRef}`)
updateUploadComment(context, ghEnv, output, { state: "skipped", message: "No new or changed chat transcripts." })

Check warning on line 606 in packages/docker-git-session-sync/src/backup.ts

View workflow job for this annotation

GitHub Actions / Lint Effect-TS

Effect migration blocker: use Effect.try / Effect.catch* instead of try/catch
return 0
}
output.out(`[session-backup] ok: ${context.source.commitSha.slice(0, 12)} (${summary.fileCount} files, ${formatBytes(summary.totalBytes)}; RTK ${formatTokenReduction(tokenReduction)})`)
Expand Down Expand Up @@ -647,7 +656,7 @@
}
try {
fs.writeFileSync(readyFilePath, `${JSON.stringify(state)}\n`, "utf8")
} catch {

Check warning on line 659 in packages/docker-git-session-sync/src/backup.ts

View workflow job for this annotation

GitHub Actions / Lint Effect-TS

Effect migration blocker: use Effect.try / Effect.catch* instead of try/catch
// The parent process also has a timeout fallback; failure to write this
// handshake file must not block updating the PR comment from the child.
}
Expand Down Expand Up @@ -692,7 +701,7 @@
const readyFilePath = path.join(os.tmpdir(), `docker-git-session-upload-ready-${Date.now()}-${Math.random().toString(16).slice(2)}.json`)
const entrypoint = currentEntrypointPath()
const args = entrypoint === null
? ["upload", "--context", contextPath, "--ready-file", readyFilePath]

Check warning on line 704 in packages/docker-git-session-sync/src/backup.ts

View workflow job for this annotation

GitHub Actions / Lint Effect-TS

Effect migration blocker: use Effect.try / Effect.catch* instead of try/catch
: [entrypoint, "upload", "--context", contextPath, "--ready-file", readyFilePath]
if (context.verbose) {
args.push("--verbose")
Expand Down Expand Up @@ -787,7 +796,7 @@
return 0
}

if (options.requireComment && !options.postComment) {

Check warning on line 799 in packages/docker-git-session-sync/src/backup.ts

View workflow job for this annotation

GitHub Actions / Lint Effect-TS

Effect migration blocker: use Effect.try / Effect.catch* instead of try/catch
output.err("[session-backup] --require-comment cannot be used with --no-comment")
return 1
}
Expand Down
2 changes: 1 addition & 1 deletion packages/docker-git-session-sync/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ const usageText = `Usage:
docker-git-session-sync download <snapshot-ref> [options]

Options:
--session-dir <path> Path under ~/.codex/sessions or ~/.claude/projects
--session-dir <path> Path under \${CODEX_HOME:-~/.codex}/sessions or \${CLAUDE_CONFIG_DIR:-~/.claude}/projects
--pr-number <number> Open PR number to post comment to
--repo <owner/repo> Source repository or list filter
--limit <number> Maximum snapshots to list (default: 20)
Expand Down
46 changes: 45 additions & 1 deletion packages/docker-git-session-sync/src/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,51 @@ export const backupDefaultBranch = "main"
export const chunkManifestSuffix = ".chunks.json"
export const maxRepoFileSize = 20 * 1000 * 1000
export const maxPushBatchBytes = 50 * 1000 * 1000
export const sessionDirNames: ReadonlyArray<string> = [".codex/sessions", ".claude/projects"]
// CHANGE: Resolve session roots from agent env overrides (CLAUDE_CONFIG_DIR / CODEX_HOME).
// WHY: docker-git points Claude Code at a custom CLAUDE_CONFIG_DIR, so chat transcripts land in
// "$CLAUDE_CONFIG_DIR/projects" rather than "~/.claude/projects". The backup only scanned the
// home-relative paths, so the Claude folder in the backup repo stayed empty (issue #422).
// QUOTE(ТЗ): "Почему сессии от claude code не созраняются ... Папка claude вообще пустая"
// REF: issue-422
// SOURCE: n/a
// FORMAT THEOREM: ∀spec,home,env: candidatePaths(spec) prefers env override base then home base.
// PURITY: CORE
// EFFECT: none
// INVARIANT: logical session name is stable regardless of which physical base is used.
// COMPLEXITY: O(1)
export interface SessionRootSpec {
// Logical name used inside the backup repo (e.g. ".claude/projects").
readonly name: string
// Environment variable that overrides the base directory, when set and non-empty.
readonly envVar: string | null
// Sub-directory holding chat transcripts within the base directory.
readonly subDir: string
// Base directory relative to the user home when the env override is absent.
readonly homeBase: string
}

export const sessionRootSpecs: ReadonlyArray<SessionRootSpec> = [
{ name: ".codex/sessions", envVar: "CODEX_HOME", subDir: "sessions", homeBase: ".codex" },
{ name: ".claude/projects", envVar: "CLAUDE_CONFIG_DIR", subDir: "projects", homeBase: ".claude" }
]

export const sessionDirNames: ReadonlyArray<string> = sessionRootSpecs.map((spec) => spec.name)

export const sessionRootCandidatePaths = (
spec: SessionRootSpec,
homeDir: string,
env: Readonly<Record<string, string | undefined>>
): ReadonlyArray<string> => {
const homePath = path.join(homeDir, spec.homeBase, spec.subDir)
const override = spec.envVar === null ? undefined : env[spec.envVar]
const trimmed = typeof override === "string" ? override.trim() : ""
if (trimmed.length === 0) {
return [homePath]
}
const overridePath = path.join(trimmed, spec.subDir)
return overridePath === homePath ? [homePath] : [overridePath, homePath]
}

export const sessionWalkIgnoreDirNames: ReadonlySet<string> = new Set([".git", "node_modules", "tmp"])
export const githubEnvKeys: ReadonlyArray<string> = ["GITHUB_TOKEN", "GH_TOKEN"]

Expand Down
4 changes: 2 additions & 2 deletions packages/docker-git-session-sync/src/snapshots.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,7 @@ export const downloadSnapshot = (options: DownloadOptions, cwd: string, output:

output.out(`Downloaded snapshot to: ${outputPath}`)
output.out("\nTo restore session files, copy them to the appropriate location:")
output.out(" - .codex/sessions/... -> ~/.codex/sessions/")
output.out(" - .claude/projects/... -> ~/.claude/projects/")
output.out(" - .codex/sessions/... -> ${CODEX_HOME:-~/.codex}/sessions/")
output.out(" - .claude/projects/... -> ${CLAUDE_CONFIG_DIR:-~/.claude}/projects/")
return 0
}
57 changes: 57 additions & 0 deletions packages/docker-git-session-sync/tests/session-files.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import {
formatTokenReduction,
isChatTranscriptPath,
maxRepoFileSize,
sessionRootCandidatePaths,
sessionRootSpecs,
shouldIgnoreSessionPath,
summarizeTokenReduction
} from "../src/core.js"
Expand Down Expand Up @@ -87,6 +89,61 @@ describe("session path filtering", () => {
})
})

describe("session root resolution", () => {
const codexSpec = sessionRootSpecs.find((spec) => spec.name === ".codex/sessions")
const claudeSpec = sessionRootSpecs.find((spec) => spec.name === ".claude/projects")

it("resolves Claude session roots from CLAUDE_CONFIG_DIR (issue #422)", () => {
expect(claudeSpec).toBeDefined()
if (claudeSpec === undefined) {
return
}
const configDir = path.join(tmpDir, ".docker-git", ".orch", "auth", "claude", "default")
const candidates = sessionRootCandidatePaths(claudeSpec, "/home/dev", {
CLAUDE_CONFIG_DIR: configDir
})

// The env override wins, but the home-relative path stays as a fallback.
expect(candidates).toEqual([
path.join(configDir, "projects"),
path.join("/home/dev", ".claude", "projects")
])
})

it("falls back to the home directory when the env override is empty", () => {
expect(codexSpec).toBeDefined()
if (codexSpec === undefined || claudeSpec === undefined) {
return
}
expect(sessionRootCandidatePaths(codexSpec, "/home/dev", {})).toEqual([
path.join("/home/dev", ".codex", "sessions")
])
expect(sessionRootCandidatePaths(claudeSpec, "/home/dev", { CLAUDE_CONFIG_DIR: " " })).toEqual([
path.join("/home/dev", ".claude", "projects")
])
})

it("resolves Codex session roots from CODEX_HOME", () => {
if (codexSpec === undefined) {
return
}
const codexHome = path.join(tmpDir, "codex-home")
expect(sessionRootCandidatePaths(codexSpec, "/home/dev", { CODEX_HOME: codexHome })).toEqual([
path.join(codexHome, "sessions"),
path.join("/home/dev", ".codex", "sessions")
])
})

it("collapses to a single candidate when the override matches the home path", () => {
if (claudeSpec === undefined) {
return
}
expect(
sessionRootCandidatePaths(claudeSpec, "/home/dev", { CLAUDE_CONFIG_DIR: "/home/dev/.claude" })
).toEqual([path.join("/home/dev", ".claude", "projects")])
})
})

describe("snapshot refs", () => {
it("uses stable current refs for PR and branch snapshots", () => {
expect(buildSnapshotRef("org/repo", 230, "issue-230")).toBe("org/repo/pr-230/current")
Expand Down
Loading