agent-carry 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,16 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ First release. Claude Code adapter.
6
+
7
+ - Export and import a session as a path-portable, redacted, merge-safe bundle (`carry export`, `carry import`, `carry inspect`, `carry list`).
8
+ - Folder sync (`carry push`, `carry pull`) with optional AES-256-GCM encryption.
9
+ - Plugin commands `/carry:share` and `/carry:pickup`, and optional session hooks.
10
+ - Redaction covers: private keys, Anthropic/OpenAI/GitHub/AWS/Google/Slack/Stripe keys, JWTs, Bearer tokens,
11
+ passwords in URLs, `NAME=value` and `"name": "value"` secrets (including after ANSI colour codes, in lower case,
12
+ and inside escaped JSON), and base64 blobs that decode to any of those.
13
+ - Credential-like text inside signed thinking blocks cannot be redacted, so export refuses unless you pass
14
+ `--strip-thinking` (drop thinking blocks) or `--allow-thinking-secrets`. `carry push` skips such sessions.
15
+ - `carry --version`. Bundles larger than 1 GB are refused, and decompression is size-capped.
16
+ - A session id that exists in several project folders is resolved by the current folder or `--project`.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 agent-carry contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,156 @@
1
- # Temporary Holding Version
1
+ # agent-carry
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Move a coding-agent session to another machine, or another folder, and keep working.
4
+
5
+ **Agents:** Claude Code is supported today. Every bundle records which agent it came from (`agent` in the header),
6
+ so other agents can be added as adapters without changing the format; a build that doesn't know an agent refuses
7
+ its bundles with a clear error instead of guessing. Codex is the intended next adapter and is **not implemented yet**.
8
+
9
+ For Claude Code: it keeps each session in `~/.claude/projects/<folder>/<id>.jsonl`, where `<folder>` is the project's
10
+ absolute path with every non-alphanumeric character turned into `-`. The transcript itself is full of absolute
11
+ paths (about three quarters of the lines in a typical one). Copy the file to another machine and `--resume` can't
12
+ find it, and if you force it, every path in the history points at a machine that isn't there.
13
+
14
+ `carry` makes a session portable:
15
+
16
+ - **Path-portable.** Your project root, home directory and session-store path become tokens on export and are
17
+ rewritten for the target machine on import. Object keys too: file-history snapshots are maps keyed by path.
18
+ - **Merge-safe.** Records have stable `uuid`s, so import is a union, not an overwrite. Importing twice changes
19
+ nothing, and a session that continued on two machines becomes a two-branch history instead of a conflict.
20
+ The local file is the source of truth: its lines are kept byte for byte.
21
+ - **Redacted.** Credential-shaped strings (cloud keys, tokens, JWTs, private keys, `*_SECRET=...`, URL passwords)
22
+ are removed before anything is written. The export prints what it removed.
23
+ - **Complete.** Subagent transcripts and offloaded tool results travel with the session, and the references to
24
+ them are rewritten.
25
+ - **Inspectable.** A bundle is newline-delimited JSON. `carry inspect` shows its header; `less` and `jq` show the rest.
26
+
27
+ ```
28
+ npm i -g agent-carry
29
+
30
+ # on machine A, in the project folder
31
+ carry list
32
+ carry export --latest -o session.carry.gz
33
+
34
+ # on machine B, in the same project (wherever it lives)
35
+ carry import session.carry.gz
36
+ claude --resume <id> # printed by import
37
+ ```
38
+
39
+ As a Claude Code plugin (`plugin/`): `/carry:share` and `/carry:pickup` wrap the same two commands.
40
+
41
+ ### Keep machines in sync through any shared folder
42
+
43
+ No server, account or key exchange: point both machines at a folder your existing sync tool already
44
+ replicates (Syncthing, Dropbox, iCloud, a git working tree, a network share).
45
+
46
+ ```
47
+ export CARRY_PASSPHRASE='long passphrase' # optional: encrypts everything written
48
+ carry push ~/Sync/carry # this project's sessions -> folder
49
+ carry pull ~/Sync/carry # other devices' sessions -> this machine
50
+ ```
51
+
52
+ Layout: `<dir>/<project>/<session-id>/<device>.carry.gz` (or `.carry.enc`). Every file has exactly one writer,
53
+ so a sync tool never sees a conflict; `pull` unions the other devices' files into your local session. The
54
+ project is identified by its git remote, not its path, so `~/work/app` and `D:\code\app` find each other. Push
55
+ skips sessions that haven't changed, so the folder doesn't churn.
56
+
57
+ With the plugin installed, set `CARRY_SYNC_DIR` and the bundled hooks pull on session start and push on session
58
+ end. (Hooks are written to the plugin spec but I have not exercised them inside a live Claude Code yet.)
59
+
60
+ | command | |
61
+ |---|---|
62
+ | `carry list [--all]` | sessions for this folder (or every folder) |
63
+ | `carry export <id\|--latest> [-o FILE]` | build a bundle. `--no-redact`, `--redact-emails`, `--strip-thinking`, `--project-root DIR` |
64
+ | `carry inspect FILE` | header: parts, redaction counts, warnings |
65
+ | `carry import FILE [--project-root DIR] [--dry-run]` | write into the target folder's session store |
66
+ | `carry push DIR` / `carry pull DIR [--name P] [--dry-run] [--quiet]` | folder sync, described above |
67
+
68
+ Passphrase: `CARRY_PASSPHRASE` or `--passphrase-file`. AES-256-GCM with a scrypt-derived key (node:crypto), header
69
+ authenticated. Never pass a passphrase as an argument; it would land in shell history.
70
+
71
+ `--claude-home` and `--home` override `~/.claude` and the home directory, which is how the tests run two
72
+ machines in one process.
73
+
74
+ ## Compared with ctxhop
75
+
76
+ [ctxhop](https://github.com/CCCCY-ci/ctxhop) is the larger, more mature tool and solves a wider problem. If you
77
+ want a continuously syncing, multi-device product with its own encrypted store, use it. Where the two differ
78
+ (from reading its README and adapter source, not from running it):
79
+
80
+ | | agent-carry | ctxhop |
81
+ |---|---|---|
82
+ | Scope | Claude Code sessions; share one, or sync a project through a folder | Claude Code + Codex, cross-agent switch, workspace and Git handoff, device authorisation, key rotation |
83
+ | Setup | none for a file; a shared folder for sync | `init` with an S3/R2/directory backend, passphrase, device invites |
84
+ | Runtime | Node, zero dependencies | Go binary, installers |
85
+ | Paths inside commands, prose and tool output | rewritten (boundary-aware); 0 leaks on 1,150 real sessions | left alone by design (only known path fields and bare paths are tokenised), so source paths and usernames stay in the history |
86
+ | Secrets inside transcript content | redacted before leaving the machine, with a report | not scanned; protected by encryption in transit and at rest |
87
+ | Sharing with someone else | a single inspectable file | designed for your own authorised devices |
88
+ | Inspectability | newline-delimited JSON, readable with `jq` | encrypted objects in a remote |
89
+ | Platforms | macOS, Linux | adds Windows |
90
+
91
+ So: carry is better when you want to *hand a session to someone*, keep secrets out of it, or sync with
92
+ nothing but a folder. ctxhop is better for an always-on personal sync across many devices and agents.
93
+
94
+ ## What is verified
95
+
96
+ - `npm test`: 36 tests on synthetic two-machine setups: planted secrets and paths, merge, tampering, encryption, folder sync convergence.
97
+ - `npm run check:real` round-trips every session in your own `~/.claude` into a temp store under a different
98
+ root, read-only on the real one. On the author's 1,150 sessions (58k records): no record lost, no dangling
99
+ parent that wasn't already dangling, no source path left outside thinking blocks, re-import byte-identical.
100
+ The same run redacted 200+ credential-shaped strings, so the problem the redactor solves is not hypothetical.
101
+
102
+ ## What is not verified, or not handled
103
+
104
+ - **`claude --resume` on an imported session** is verified on one real session (900 KB, 418 paths rewritten,
105
+ Claude Code 2.1.299): resumed from a folder it never ran in, it recalled its first request and reported the new
106
+ working directory. That is one session on one version. Run `scripts/verify-resume.sh` on yours. The transcript
107
+ format is internal and undocumented and can change; carry parses tolerantly and carries unknown lines through.
108
+ - **Thinking blocks are signed**, so they are carried verbatim: not path-rewritten, not redacted. If one contains
109
+ a credential-like string, export **refuses** unless you pass `--strip-thinking` (drop thinking blocks; resume
110
+ with stripped thinking is untested) or `--allow-thinking-secrets`. `carry push` skips those sessions.
111
+ - **Redaction is pattern-based and best effort.** It will not find a password that looks like a sentence, or a
112
+ letters-only lower-case `password=correcthorse` (lower-case names need a digit in the value, to avoid mangling
113
+ code like `token = parseToken(x)`). ALL_CAPS names like `DB_PASSWORD=` are always redacted. Base64 is only
114
+ caught when it decodes to something the other rules catch. A bundle
115
+ is not encrypted. Don't commit one to a public repo, and read `carry inspect` before sharing.
116
+ - **Cowork / desktop**: not checked. If it stores sessions somewhere else, it needs its own adapter.
117
+ - **Push re-uploads sessions you pulled** (one file per device, merged on pull), so folder size grows with devices x sessions.
118
+ - **Windows paths** (drive letters, backslashes) are not handled. Folder names longer than ~200 characters may
119
+ be shortened differently by Claude Code; import warns.
120
+ - **File-history backups** (`~/.claude/file-history`, used for rewind) are not carried.
121
+ - Relative traversal text like `../../Users/x` is tokenised for privacy, so it is cosmetically wrong after import.
122
+
123
+ ## Verify resume yourself
124
+
125
+ ```
126
+ node src/cli.js list --all # pick a session with real content
127
+ sh scripts/verify-resume.sh <session-id-prefix>
128
+ ```
129
+
130
+ Exports the session, imports it into a throwaway folder, runs `claude -p --resume` there, and deletes both the
131
+ folder and the imported copy. The original is only read. PASS means the answer matches that session's first
132
+ request and names the new folder.
133
+
134
+ ## Adding another agent
135
+
136
+ Everything Claude-specific is in four small places: how a project maps to a session folder (`src/paths.js`), how a
137
+ session and its sidecar files are found and read (`src/store.js`), how a transcript line is identified and
138
+ summarised (`src/transcript.js`), and the `AGENT` constant stamped into bundles (`src/bundle.js`). Path rewriting,
139
+ redaction, merging, encryption and folder sync work on parsed JSON and don't care which agent produced it.
140
+ An adapter for Codex would supply those four pieces for its own session format.
141
+
142
+ ## Layout
143
+
144
+ ```
145
+ src/paths.js folder-key mangling, claude home
146
+ src/portable.js path <-> token rewriting (boundary-aware, keys included, thinking skipped)
147
+ src/redact.js secret patterns
148
+ src/merge.js uuid union; local lines kept first
149
+ src/store.js list / read a session and its sidecar files
150
+ src/bundle.js build, read, import; integrity hash; path-traversal guard
151
+ src/crypto.js passphrase encryption (scrypt + AES-256-GCM)
152
+ src/sync.js push / pull over a shared folder
153
+ src/cli.js carry
154
+ ```
155
+
156
+ MIT.
package/package.json CHANGED
@@ -1,6 +1,44 @@
1
1
  {
2
2
  "name": "agent-carry",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Move coding-agent sessions between machines and folders: path-portable, redacted, encrypted, merge-safe bundles. Claude Code today; adapter-ready for Codex and others.",
5
+ "type": "module",
6
+ "bin": {
7
+ "carry": "src/cli.js"
8
+ },
9
+ "files": [
10
+ "src",
11
+ "plugin",
12
+ "README.md",
13
+ "LICENSE",
14
+ "scripts/verify-resume.sh",
15
+ "CHANGELOG.md"
16
+ ],
17
+ "engines": {
18
+ "node": ">=20"
19
+ },
20
+ "scripts": {
21
+ "test": "node --test \"test/*.test.js\"",
22
+ "check:real": "node scripts/check-real.js"
23
+ },
24
+ "keywords": [
25
+ "claude-code",
26
+ "codex",
27
+ "coding-agent",
28
+ "sessions",
29
+ "transcripts",
30
+ "portable",
31
+ "resume",
32
+ "sync"
33
+ ],
34
+ "license": "MIT",
35
+ "repository": {
36
+ "type": "git",
37
+ "url": "git+https://github.com/harshhh28/agent-carry.git"
38
+ },
39
+ "homepage": "https://github.com/harshhh28/agent-carry#readme",
40
+ "bugs": {
41
+ "url": "https://github.com/harshhh28/agent-carry/issues"
42
+ },
43
+ "author": "Harsh Gajjar <harshgajjar2812@gmail.com>"
44
+ }
@@ -0,0 +1,7 @@
1
+ {
2
+ "name": "carry",
3
+ "version": "0.1.0",
4
+ "description": "Move coding-agent sessions between machines and folders: /carry:share exports a redacted, path-portable bundle, /carry:pickup imports one.",
5
+ "author": { "name": "agent-carry contributors" },
6
+ "license": "MIT"
7
+ }
@@ -0,0 +1,13 @@
1
+ ---
2
+ description: Import a carry bundle into this folder so it can be resumed
3
+ argument-hint: <bundle-file> [--dry-run]
4
+ allowed-tools: Bash(carry import:*)
5
+ ---
6
+
7
+ Import result:
8
+
9
+ !`carry import $ARGUMENTS 2>&1 || echo "carry is not installed: run npm i -g agent-carry"`
10
+
11
+ Tell the user the session was imported into this folder and that a running session cannot swap its own
12
+ history: they should exit and run the `claude --resume <id>` line printed above. If `--dry-run` was used,
13
+ say nothing was written.
@@ -0,0 +1,13 @@
1
+ ---
2
+ description: Export this project's latest session as a portable, redacted carry bundle
3
+ argument-hint: [-o FILE] [--strip-thinking] [--redact-emails]
4
+ allowed-tools: Bash(carry export:*)
5
+ ---
6
+
7
+ Export result (this runs the `carry` CLI from `npm i -g agent-carry`):
8
+
9
+ !`carry export --latest $ARGUMENTS 2>&1 || echo "carry is not installed: run npm i -g agent-carry"`
10
+
11
+ Report in at most three lines: the file that was written, how many secrets were redacted, and any warning above.
12
+ If there is a warning about credential-like strings in thinking blocks, tell the user to re-run `/carry:share --strip-thinking`.
13
+ Do not open or print the bundle's contents.
@@ -0,0 +1,10 @@
1
+ {
2
+ "hooks": {
3
+ "SessionStart": [
4
+ { "hooks": [ { "type": "command", "command": "[ -n \"$CARRY_SYNC_DIR\" ] && carry pull \"$CARRY_SYNC_DIR\" --quiet || true" } ] }
5
+ ],
6
+ "SessionEnd": [
7
+ { "hooks": [ { "type": "command", "command": "[ -n \"$CARRY_SYNC_DIR\" ] && carry push \"$CARRY_SYNC_DIR\" --quiet || true" } ] }
8
+ ]
9
+ }
10
+ }
@@ -0,0 +1,33 @@
1
+ #!/bin/sh
2
+ # Does `claude --resume` accept a session imported by carry into a folder it never ran in?
3
+ #
4
+ # sh scripts/verify-resume.sh <session-id-prefix> (ids: node src/cli.js list --all)
5
+ #
6
+ # Exports the session, imports it into a throwaway folder, resumes it with one
7
+ # short prompt, then deletes the throwaway folder and its copy of the session.
8
+ # The original session is only read. Costs one small API call.
9
+ set -eu
10
+
11
+ ID=${1:?usage: verify-resume.sh <session-id-prefix>}
12
+ HERE=$(cd "$(dirname "$0")/.." && pwd -P)
13
+ CARRY="node $HERE/src/cli.js"
14
+ CLAUDE_DIR=${CLAUDE_CONFIG_DIR:-$HOME/.claude}
15
+
16
+ TRY=$(mktemp -d "${TMPDIR:-/tmp}/carry-try.XXXXXX")
17
+ TRY=$(cd "$TRY" && pwd -P)
18
+ FOLDER="$CLAUDE_DIR/projects/$(printf '%s' "$TRY" | sed 's/[^A-Za-z0-9]/-/g')"
19
+ cleanup() { rm -rf "$TRY" "$FOLDER"; echo "cleaned up $TRY and its session copy"; }
20
+ trap cleanup EXIT
21
+
22
+ echo "1/3 export"
23
+ $CARRY export "$ID" -o "$TRY/s.carry.gz"
24
+ FULL=$($CARRY inspect "$TRY/s.carry.gz" | sed -n 's/.*"sessionId": "\(.*\)".*/\1/p')
25
+
26
+ echo "2/3 import into $TRY"
27
+ cd "$TRY"
28
+ $CARRY import "$TRY/s.carry.gz" >/dev/null
29
+
30
+ echo "3/3 resume $FULL from a folder it never ran in"
31
+ claude -p --resume "$FULL" "In one sentence: what was the very first thing the user asked in this conversation? Also say which directory we are in now." </dev/null
32
+ echo
33
+ echo "PASS if the answer above matches the session's first request and names $TRY."
package/src/bundle.js ADDED
@@ -0,0 +1,221 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import zlib from 'node:zlib';
4
+ import { parseJsonl, summarize, sha256 } from './transcript.js';
5
+ import { toPortable, fromPortable, mapStrings, residualHomePaths, TOKENS } from './portable.js';
6
+ import { makeRedactor } from './redact.js';
7
+ import { mergeEntries } from './merge.js';
8
+ import { mangle, realpathOr, projectsDir } from './paths.js';
9
+ import { encrypt, decrypt, isEncrypted } from './crypto.js';
10
+ import { readSession } from './store.js';
11
+
12
+ export const FORMAT = 1;
13
+ const MAX_BUNDLE_BYTES = 1024 * 1024 * 1024; // refuse decompression bombs
14
+
15
+ export const TOOL = 'agent-carry/0.1.0';
16
+
17
+ /** Agents this build can read and write. Bundles name their agent so other adapters can be added without a format change. */
18
+ export const AGENT = 'claude-code';
19
+ const SUPPORTED_AGENTS = new Set([AGENT]);
20
+
21
+ /**
22
+ * Bundle = newline-delimited JSON, readable with `less` and `jq`:
23
+ * line 1 header (what is inside, counts, redaction report, body hash)
24
+ * line 2+ {"p": part, "l": record} | {"p": part, "x": unparsable line}
25
+ * | {"p": part, "t": text file} | {"p": part, "b": base64 file}
26
+ * A `.gz` suffix gzips the whole thing.
27
+ */
28
+
29
+ export function buildBundle({ folder, id, where, redact = true, emails = false, stripThinking = false, allowThinkingSecrets = false }) {
30
+ const { parts, skipped } = readSession(folder, id);
31
+ const mapper = toPortable(where);
32
+ const redactor = redact ? makeRedactor({ emails }) : null;
33
+ const transform = (v) => {
34
+ let out = mapStrings(v, mapper.fn);
35
+ if (redactor) out = redactor.apply(out);
36
+ return out;
37
+ };
38
+
39
+ const body = [];
40
+ const thinking = { blocks: 0, possibleSecrets: 0 };
41
+ const partsMeta = [];
42
+ const residualSource = [];
43
+ let meta = null;
44
+
45
+ for (const part of parts) {
46
+ let count = 0;
47
+ if (part.kind === 'jsonl') {
48
+ const entries = parseJsonl(part.text);
49
+ if (part.rel === 'main.jsonl') meta = summarize(entries);
50
+ for (const { raw, rec } of entries) {
51
+ if (rec === null) {
52
+ const t = transform(raw);
53
+ body.push(JSON.stringify({ p: part.rel, x: t }));
54
+ residualSource.push(t);
55
+ } else {
56
+ let r = stripThinking ? dropThinking(rec) : rec;
57
+ if (!stripThinking) scanThinking(r, thinking);
58
+ r = transform(r);
59
+ body.push(JSON.stringify({ p: part.rel, l: r }));
60
+ residualSource.push(JSON.stringify(r));
61
+ }
62
+ count++;
63
+ }
64
+ } else if (part.kind === 'text') {
65
+ const t = transform(part.text);
66
+ body.push(JSON.stringify({ p: part.rel, t }));
67
+ residualSource.push(t);
68
+ count = 1;
69
+ } else {
70
+ body.push(JSON.stringify({ p: part.rel, b: part.base64 }));
71
+ count = 1;
72
+ }
73
+ partsMeta.push({ rel: part.rel, kind: part.kind, count });
74
+ }
75
+
76
+ if (redact && !stripThinking && !allowThinkingSecrets && thinking.possibleSecrets > 0) {
77
+ const e = new Error(
78
+ `${thinking.possibleSecrets} credential-like string(s) in ${thinking.blocks} signed thinking block(s) cannot be redacted (editing a thinking block invalidates it). ` +
79
+ 'Re-run with --strip-thinking to drop thinking blocks, or --allow-thinking-secrets to export them as they are.',
80
+ );
81
+ e.code = 'THINKING_SECRETS';
82
+ throw e;
83
+ }
84
+ const bodyText = body.join('\n') + '\n';
85
+ const header = {
86
+ carry: FORMAT,
87
+ agent: AGENT,
88
+ tool: TOOL,
89
+ createdAt: new Date().toISOString(),
90
+ sessionId: id,
91
+ title: meta?.title ?? null,
92
+ claudeVersions: meta?.versions ?? [],
93
+ entrypoints: meta?.entrypoints ?? [],
94
+ turns: meta?.turns ?? 0,
95
+ tokens: TOKENS,
96
+ parts: partsMeta,
97
+ skippedLarge: skipped,
98
+ redaction: redactor ? { enabled: true, emails, counts: redactor.counts } : { enabled: false },
99
+ thinkingStripped: stripThinking,
100
+ thinking,
101
+ pathsRewritten: mapper.stats.replaced,
102
+ residualHomePaths: residualHomePaths(residualSource).size,
103
+ bodySha256: sha256(bodyText),
104
+ };
105
+ return { text: JSON.stringify(header) + '\n' + bodyText, header };
106
+ }
107
+
108
+ /**
109
+ * Thinking blocks are signed, so they travel unmodified: neither path-rewritten
110
+ * nor redacted. Count credential-shaped strings inside them so the exporter can
111
+ * warn instead of silently shipping them.
112
+ */
113
+ function scanThinking(rec, acc) {
114
+ const c = rec?.message?.content;
115
+ if (!Array.isArray(c)) return;
116
+ for (const b of c) {
117
+ if (!b || b.type !== 'thinking' || typeof b.thinking !== 'string') continue;
118
+ acc.blocks++;
119
+ const probe = makeRedactor();
120
+ probe.fn(b.thinking);
121
+ acc.possibleSecrets += Object.values(probe.counts).reduce((a, n) => a + n, 0);
122
+ }
123
+ }
124
+
125
+ function dropThinking(rec) {
126
+ const c = rec?.message?.content;
127
+ if (!Array.isArray(c)) return rec;
128
+ const kept = c.filter((b) => !(b && (b.type === 'thinking' || b.type === 'redacted_thinking')));
129
+ return kept.length === c.length ? rec : { ...rec, message: { ...rec.message, content: kept } };
130
+ }
131
+
132
+ /** Atomic (temp + rename) so a file-sync tool never uploads a half-written bundle. */
133
+ export function writeBundle(file, text, { passphrase } = {}) {
134
+ let data = Buffer.from(text);
135
+ if (file.endsWith('.gz') || file.endsWith('.enc') || passphrase) data = zlib.gzipSync(data);
136
+ if (passphrase) data = encrypt(data, passphrase);
137
+ fs.mkdirSync(path.dirname(path.resolve(file)), { recursive: true });
138
+ const tmp = `${file}.carry-tmp`;
139
+ fs.writeFileSync(tmp, data);
140
+ fs.renameSync(tmp, file);
141
+ }
142
+
143
+ export function readBundle(file, { passphrase } = {}) {
144
+ if (fs.statSync(file).size > MAX_BUNDLE_BYTES) throw new Error('bundle is larger than 1 GB; refusing to read it');
145
+ let buf = fs.readFileSync(file);
146
+ if (isEncrypted(buf)) buf = decrypt(buf, passphrase);
147
+ if (buf[0] === 0x1f && buf[1] === 0x8b) buf = zlib.gunzipSync(buf, { maxOutputLength: MAX_BUNDLE_BYTES });
148
+ const text = buf.toString('utf8');
149
+ const nl = text.indexOf('\n');
150
+ if (nl < 0) throw new Error('not a carry bundle (no header)');
151
+ let header;
152
+ try {
153
+ header = JSON.parse(text.slice(0, nl));
154
+ } catch {
155
+ throw new Error('not a carry bundle (bad header)');
156
+ }
157
+ if (header.carry !== FORMAT) throw new Error(`unsupported bundle format ${header.carry} (this tool reads ${FORMAT})`);
158
+ // Bundles written before the field existed were all Claude Code.
159
+ const agent = header.agent ?? AGENT;
160
+ if (!SUPPORTED_AGENTS.has(agent)) {
161
+ throw new Error(`bundle is for agent "${agent}"; this version of agent-carry supports: ${[...SUPPORTED_AGENTS].join(', ')}`);
162
+ }
163
+ const bodyText = text.slice(nl + 1);
164
+ if (sha256(bodyText) !== header.bodySha256) throw new Error('bundle is corrupt or was modified: body hash mismatch');
165
+ const rows = bodyText.split('\n').filter(Boolean).map((l) => JSON.parse(l));
166
+ return { header, rows };
167
+ }
168
+
169
+ /**
170
+ * Write a bundle into a Claude home for a target project root. Returns a plan;
171
+ * with dryRun it writes nothing.
172
+ */
173
+ export function importBundle({ bundle, claudeHome, root, home, dryRun = false }) {
174
+ const realRoot = realpathOr(root);
175
+ const folder = path.join(projectsDir(claudeHome), mangle(realRoot));
176
+ const where = { store: folder, root: realRoot, home };
177
+ const back = fromPortable(where);
178
+ const id = bundle.header.sessionId;
179
+ const plan = { id, folder, root: realRoot, files: [], warnings: [] };
180
+ if (mangle(realRoot).length > 200) {
181
+ plan.warnings.push('project path is very long; Claude Code may shorten the folder name differently than carry does');
182
+ }
183
+
184
+ const byPart = new Map();
185
+ for (const row of bundle.rows) {
186
+ if (!byPart.has(row.p)) byPart.set(row.p, []);
187
+ byPart.get(row.p).push(row);
188
+ }
189
+
190
+ for (const [rel, rows] of byPart) {
191
+ const dest = rel === 'main.jsonl' ? path.join(folder, `${id}.jsonl`) : path.join(folder, id, rel.replace(/^side\//, ''));
192
+ if (!path.resolve(dest).startsWith(path.resolve(folder) + path.sep)) throw new Error(`refusing path outside project folder: ${rel}`);
193
+ const kind = rows[0].l !== undefined || rows[0].x !== undefined ? 'jsonl' : rows[0].t !== undefined ? 'text' : 'binary';
194
+ let content;
195
+ let stat;
196
+ if (kind === 'jsonl') {
197
+ const incoming = rows.map((r) => {
198
+ const raw = r.l !== undefined ? JSON.stringify(mapStrings(r.l, back.fn)) : back.fn(r.x);
199
+ return { raw, rec: r.l !== undefined ? JSON.parse(raw) : null };
200
+ });
201
+ const local = fs.existsSync(dest) ? parseJsonl(fs.readFileSync(dest, 'utf8')) : [];
202
+ const m = mergeEntries(local, incoming);
203
+ content = m.lines.join('\n') + '\n';
204
+ stat = { existed: local.length > 0, added: m.added, skipped: m.skipped };
205
+ } else if (fs.existsSync(dest)) {
206
+ stat = { existed: true, added: 0, skipped: 1 };
207
+ content = null; // sidecar files are content-addressed by name; keep the local one
208
+ } else {
209
+ content = kind === 'text' ? back.fn(rows[0].t) : Buffer.from(rows[0].b, 'base64');
210
+ stat = { existed: false, added: 1, skipped: 0 };
211
+ }
212
+ plan.files.push({ rel, dest, kind, ...stat });
213
+ if (!dryRun && content !== null && (kind !== 'jsonl' || stat.added > 0 || !stat.existed)) {
214
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
215
+ const tmp = `${dest}.carry-tmp`;
216
+ fs.writeFileSync(tmp, content);
217
+ fs.renameSync(tmp, dest);
218
+ }
219
+ }
220
+ return plan;
221
+ }
package/src/cli.js ADDED
@@ -0,0 +1,172 @@
1
+ #!/usr/bin/env node
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { claudeHome, mangle, projectsDir, realpathOr } from './paths.js';
5
+ import { projectFolders, listSessions, findSession } from './store.js';
6
+ import { buildBundle, writeBundle, readBundle, importBundle } from './bundle.js';
7
+ import { parseJsonl, summarize } from './transcript.js';
8
+ import fs from 'node:fs';
9
+ import { pushProject, pullProject } from './sync.js';
10
+
11
+ const HELP = `carry (agent-carry): move coding-agent sessions between machines and folders. Supported agent: claude-code
12
+
13
+ carry list [--all] [--project DIR]
14
+ carry export <id|prefix|--latest> [-o FILE] [--project-root DIR]
15
+ [--no-redact] [--redact-emails] [--strip-thinking | --allow-thinking-secrets]
16
+ carry inspect <bundle>
17
+ carry import <bundle> [--project-root DIR] [--dry-run]
18
+ carry push <dir> [--name PROJECT] [--quiet] sync every session of this project to a shared folder
19
+ carry pull <dir> [--name PROJECT] [--dry-run] [--quiet]
20
+
21
+ Encryption (export, import, push, pull): set CARRY_PASSPHRASE or pass --passphrase-file FILE.
22
+
23
+ Global: --claude-home DIR (default $CLAUDE_CONFIG_DIR or ~/.claude), --home DIR
24
+ `;
25
+
26
+ function parseArgs(argv) {
27
+ const flags = {};
28
+ const pos = [];
29
+ const valued = new Set(['-o', '--out', '--project', '--project-root', '--claude-home', '--home', '--name', '--passphrase-file']);
30
+ for (let i = 0; i < argv.length; i++) {
31
+ const a = argv[i];
32
+ if (a.startsWith('-') && a !== '-') {
33
+ if (valued.has(a)) flags[a.replace(/^-+/, '')] = argv[++i];
34
+ else flags[a.replace(/^-+/, '')] = true;
35
+ } else pos.push(a);
36
+ }
37
+ return { flags, pos };
38
+ }
39
+
40
+ function fail(msg) {
41
+ process.stderr.write(`carry: ${msg}\n`);
42
+ process.exit(1);
43
+ }
44
+
45
+ function main() {
46
+ const [cmd, ...rest] = process.argv.slice(2);
47
+ if (cmd === '--version' || cmd === '-v' || cmd === 'version') {
48
+ const pkg = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
49
+ return process.stdout.write(`${pkg.version}\n`);
50
+ }
51
+ const { flags, pos } = parseArgs(rest);
52
+ const home = flags['claude-home'] ? path.resolve(flags['claude-home']) : claudeHome();
53
+ const userHome = flags.home ? path.resolve(flags.home) : os.homedir();
54
+ const cwd = flags['project-root'] || flags.project || process.cwd();
55
+ const passphrase = flags['passphrase-file'] ? fs.readFileSync(flags['passphrase-file'], 'utf8').replace(/\r?\n$/, '') : process.env.CARRY_PASSPHRASE || undefined;
56
+ const say = (m) => flags.quiet || process.stdout.write(`${m}\n`);
57
+
58
+ try {
59
+ if (!cmd || cmd === 'help' || flags.help) return process.stdout.write(HELP);
60
+
61
+ if (cmd === 'list') {
62
+ const folders = flags.all ? projectFolders(home) : [path.join(projectsDir(home), mangle(realpathOr(cwd)))];
63
+ let n = 0;
64
+ for (const f of folders) {
65
+ for (const s of listSessions(f)) {
66
+ n++;
67
+ const when = new Date(s.mtimeMs).toISOString().slice(0, 16).replace('T', ' ');
68
+ const where = flags.all ? ` ${s.dominantCwd ?? path.basename(f)}` : '';
69
+ process.stdout.write(`${s.id.slice(0, 8)} ${when} ${String(s.turns).padStart(3)} turns ${s.title ?? '(untitled)'}${where}\n`);
70
+ }
71
+ }
72
+ if (!n) process.stdout.write(`no sessions found for ${flags.all ? 'any project' : realpathOr(cwd)}\n`);
73
+ return;
74
+ }
75
+
76
+ if (cmd === 'export') {
77
+ const folders = flags.all || !flags.latest ? projectFolders(home) : [path.join(projectsDir(home), mangle(realpathOr(cwd)))];
78
+ let target;
79
+ if (flags.latest) {
80
+ const here = path.join(projectsDir(home), mangle(realpathOr(cwd)));
81
+ const s = listSessions(here)[0];
82
+ if (!s) fail(`no sessions for ${realpathOr(cwd)}`);
83
+ target = { folder: here, id: s.id };
84
+ } else {
85
+ if (!pos[0]) fail('export needs a session id (see `carry list`) or --latest');
86
+ // Same id can exist in several project folders (a session that was carried). Prefer the folder
87
+ // named by --project, else the current one, and only then search everything.
88
+ const scoped = path.join(projectsDir(home), mangle(realpathOr(flags.project || process.cwd())));
89
+ try {
90
+ target = findSession([scoped], pos[0]);
91
+ } catch (e) {
92
+ if (flags.project) throw e;
93
+ target = findSession(folders, pos[0]);
94
+ }
95
+ }
96
+ const entries = parseJsonl(fs.readFileSync(path.join(target.folder, `${target.id}.jsonl`), 'utf8'));
97
+ const sum = summarize(entries);
98
+ const root = flags['project-root'] ? realpathOr(flags['project-root']) : sum.dominantCwd;
99
+ if (!root) fail('could not determine the project root from the transcript; pass --project-root');
100
+ const { text, header } = buildBundle({
101
+ folder: target.folder,
102
+ id: target.id,
103
+ where: { store: target.folder, root, home: userHome },
104
+ redact: !flags['no-redact'],
105
+ emails: !!flags['redact-emails'],
106
+ stripThinking: !!flags['strip-thinking'],
107
+ allowThinkingSecrets: !!flags['allow-thinking-secrets'],
108
+ });
109
+ const out = flags.o || flags.out || `${target.id.slice(0, 8)}${passphrase ? '.carry.enc' : '.carry.gz'}`;
110
+ writeBundle(out, text, { passphrase });
111
+ const red = Object.entries(header.redaction.counts || {}).map(([k, v]) => `${k}:${v}`).join(' ') || 'none found';
112
+ process.stdout.write(
113
+ `exported ${header.sessionId.slice(0, 8)} "${header.title ?? 'untitled'}" -> ${out}\n` +
114
+ ` parts: ${header.parts.length} paths rewritten: ${header.pathsRewritten}\n` +
115
+ ` redacted: ${header.redaction.enabled ? red : 'OFF'} encrypted: ${passphrase ? 'yes' : 'no'}\n`,
116
+ );
117
+ if (header.residualHomePaths > 0) {
118
+ process.stderr.write(` warning: ${header.residualHomePaths} other absolute home path(s) remain in the bundle (other users or tools). Review with \`carry inspect\`.\n`);
119
+ }
120
+ if (header.thinking.possibleSecrets > 0) {
121
+ process.stderr.write(` warning: ${header.thinking.possibleSecrets} credential-like string(s) in ${header.thinking.blocks} signed thinking block(s). These cannot be redacted without invalidating them; re-export with --strip-thinking.\n`);
122
+ }
123
+ if (header.skippedLarge.length) process.stderr.write(` warning: ${header.skippedLarge.length} file(s) over 20MB were not included\n`);
124
+ return;
125
+ }
126
+
127
+ if (cmd === 'inspect') {
128
+ if (!pos[0]) fail('inspect needs a bundle file');
129
+ const { header } = readBundle(pos[0], { passphrase });
130
+ process.stdout.write(JSON.stringify(header, null, 2) + '\n');
131
+ return;
132
+ }
133
+
134
+ if (cmd === 'import') {
135
+ if (!pos[0]) fail('import needs a bundle file');
136
+ const bundle = readBundle(pos[0], { passphrase });
137
+ const plan = importBundle({ bundle, claudeHome: home, root: cwd, home: userHome, dryRun: !!flags['dry-run'] });
138
+ process.stdout.write(`${flags['dry-run'] ? 'would import' : 'imported'} ${plan.id.slice(0, 8)} into ${plan.folder}\n`);
139
+ for (const f of plan.files.filter((f) => f.kind === 'jsonl')) {
140
+ process.stdout.write(` ${f.rel}: +${f.added} new, ${f.skipped} already present\n`);
141
+ }
142
+ const side = plan.files.filter((f) => f.kind !== 'jsonl');
143
+ if (side.length) process.stdout.write(` sidecar files: ${side.filter((f) => f.added).length} new, ${side.filter((f) => !f.added).length} kept\n`);
144
+ for (const w of plan.warnings) process.stderr.write(` warning: ${w}\n`);
145
+ if (!flags['dry-run']) process.stdout.write(`\nresume with:\n cd ${plan.root} && claude --resume ${plan.id}\n`);
146
+ return;
147
+ }
148
+
149
+ if (cmd === 'push' || cmd === 'pull') {
150
+ if (!pos[0]) fail(`${cmd} needs a shared folder, e.g. carry ${cmd} ~/Sync/carry`);
151
+ const dir = path.resolve(pos[0]);
152
+ const common = { dir, claudeHome: home, root: cwd, userHome, passphrase, name: flags.name, log: say };
153
+ if (cmd === 'push') {
154
+ const r = pushProject(common);
155
+ if (r.skipped.length) process.stderr.write(` ${r.skipped.length} session(s) skipped: credential-like text in thinking blocks\n`);
156
+ say(`push ${r.key}: ${r.pushed} written, ${r.unchanged} unchanged (device ${r.device}${passphrase ? ', encrypted' : ''})`);
157
+ } else {
158
+ const r = pullProject({ ...common, dryRun: !!flags['dry-run'] });
159
+ say(`pull ${r.key}: ${r.imported} session(s) updated, ${r.upToDate} up to date`);
160
+ for (const e of r.errors) process.stderr.write(` error: ${e}\n`);
161
+ if (r.errors.length) process.exitCode = 1;
162
+ }
163
+ return;
164
+ }
165
+
166
+ fail(`unknown command "${cmd}"\n\n${HELP}`);
167
+ } catch (e) {
168
+ fail(e.message);
169
+ }
170
+ }
171
+
172
+ main();
package/src/crypto.js ADDED
@@ -0,0 +1,43 @@
1
+ import crypto from 'node:crypto';
2
+
3
+ /**
4
+ * Passphrase encryption for bundles: scrypt -> AES-256-GCM, node:crypto only.
5
+ * Layout: "CARRYENC" | version(1) | salt(16) | iv(12) | ciphertext | tag(16).
6
+ * The fixed header is bound into the auth tag, so any edit is detected.
7
+ */
8
+ const MAGIC = Buffer.from('CARRYENC');
9
+ const VERSION = 1;
10
+ const HEADER = MAGIC.length + 1 + 16 + 12;
11
+ const SCRYPT = { N: 2 ** 15, r: 8, p: 1, maxmem: 64 * 1024 * 1024 };
12
+
13
+ export const isEncrypted = (buf) => buf.length > HEADER + 16 && buf.subarray(0, MAGIC.length).equals(MAGIC);
14
+
15
+ export function encrypt(plain, passphrase) {
16
+ if (!passphrase) throw new Error('empty passphrase');
17
+ const salt = crypto.randomBytes(16);
18
+ const iv = crypto.randomBytes(12);
19
+ const key = crypto.scryptSync(passphrase, salt, 32, SCRYPT);
20
+ const head = Buffer.concat([MAGIC, Buffer.from([VERSION]), salt, iv]);
21
+ const c = crypto.createCipheriv('aes-256-gcm', key, iv);
22
+ c.setAAD(head);
23
+ const ct = Buffer.concat([c.update(plain), c.final()]);
24
+ return Buffer.concat([head, ct, c.getAuthTag()]);
25
+ }
26
+
27
+ export function decrypt(buf, passphrase) {
28
+ if (!passphrase) throw new Error('this bundle is encrypted: set CARRY_PASSPHRASE or pass --passphrase-file');
29
+ if (buf[MAGIC.length] !== VERSION) throw new Error(`unsupported encryption version ${buf[MAGIC.length]}`);
30
+ const head = buf.subarray(0, HEADER);
31
+ const salt = head.subarray(MAGIC.length + 1, MAGIC.length + 17);
32
+ const iv = head.subarray(MAGIC.length + 17);
33
+ const tag = buf.subarray(buf.length - 16);
34
+ const key = crypto.scryptSync(passphrase, salt, 32, SCRYPT);
35
+ const d = crypto.createDecipheriv('aes-256-gcm', key, iv);
36
+ d.setAAD(head);
37
+ d.setAuthTag(tag);
38
+ try {
39
+ return Buffer.concat([d.update(buf.subarray(HEADER, buf.length - 16)), d.final()]);
40
+ } catch {
41
+ throw new Error('wrong passphrase, or the file was modified');
42
+ }
43
+ }
package/src/merge.js ADDED
@@ -0,0 +1,29 @@
1
+ import { recordKey } from './transcript.js';
2
+
3
+ /**
4
+ * Union of two transcripts keyed by record identity. The local file is the
5
+ * source of truth: its lines are kept byte for byte and in order, and records
6
+ * that only exist in the incoming copy are appended. A session that continued
7
+ * on two machines therefore becomes a DAG with two branches, which is exactly
8
+ * what it is, and merging the same bundle twice changes nothing.
9
+ */
10
+ export function mergeEntries(local, incoming) {
11
+ const localKeys = new Set(local.map(recordKey));
12
+ const lines = local.map((e) => e.raw);
13
+ const seenUuid = new Set();
14
+ let added = 0;
15
+ for (const e of incoming) {
16
+ const k = recordKey(e);
17
+ if (localKeys.has(k)) continue;
18
+ // Conversation records are unique by uuid. Bookkeeping records (mode,
19
+ // last-prompt, ...) have no identity and their *order* carries meaning
20
+ // (the last one wins), so repeats inside the incoming copy are kept.
21
+ if (k.startsWith('u:')) {
22
+ if (seenUuid.has(k)) continue;
23
+ seenUuid.add(k);
24
+ }
25
+ lines.push(e.raw);
26
+ added++;
27
+ }
28
+ return { lines, added, kept: local.length, skipped: incoming.length - added };
29
+ }
package/src/paths.js ADDED
@@ -0,0 +1,26 @@
1
+ import os from 'node:os';
2
+ import path from 'node:path';
3
+ import fs from 'node:fs';
4
+
5
+ /**
6
+ * Claude Code stores a project's sessions in a folder named after the project's
7
+ * working directory with every non-alphanumeric character replaced by '-'.
8
+ * The mapping is lossy (`/a/b-c` and `/a/b/c` collide), so it can only be
9
+ * computed forward, never inverted. That is why a session cannot simply be
10
+ * copied to another machine: the folder key depends on the absolute path.
11
+ */
12
+ export const mangle = (absPath) => absPath.replace(/[^A-Za-z0-9]/g, '-');
13
+
14
+ export function claudeHome(env = process.env) {
15
+ return env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
16
+ }
17
+
18
+ export const projectsDir = (home) => path.join(home, 'projects');
19
+
20
+ export function realpathOr(p) {
21
+ try {
22
+ return fs.realpathSync(p);
23
+ } catch {
24
+ return path.resolve(p);
25
+ }
26
+ }
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Path portability. Absolute paths are swapped for tokens on export and for the
3
+ * target machine's paths on import. Order matters: the project's session store
4
+ * lives under HOME and contains the project root in mangled form, so the
5
+ * longest, most specific prefix is replaced first.
6
+ */
7
+ export const TOKENS = {
8
+ store: '${CARRY_STORE}',
9
+ root: '${CARRY_ROOT}',
10
+ home: '${CARRY_HOME}',
11
+ };
12
+
13
+ const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
14
+
15
+ // A path only matches as a whole segment run: `/work/app` must not touch
16
+ // `/work/app-two` or `/work/app.bak`, and must not match inside `/x/work/app`.
17
+ const prefixRe = (abs) =>
18
+ new RegExp(`(?:(?<![A-Za-z0-9])|(?<=\\\\[nrt])|(?<=(?:\\x1b|u001b)\\[[0-9;]*m))${escapeRe(abs)}(?![A-Za-z0-9_-]|\\.[A-Za-z0-9])`, 'g');
19
+
20
+ const THINKING = new Set(['thinking', 'redacted_thinking']);
21
+
22
+ /**
23
+ * Apply `fn` to every string value in a parsed record. Thinking blocks are
24
+ * skipped on purpose: they carry a cryptographic signature and the API rejects
25
+ * a resumed history in which one was edited.
26
+ */
27
+ export function mapStrings(value, fn) {
28
+ if (typeof value === 'string') return fn(value);
29
+ if (Array.isArray(value)) {
30
+ let changed = false;
31
+ const out = value.map((v) => {
32
+ const m = mapStrings(v, fn);
33
+ if (m !== v) changed = true;
34
+ return m;
35
+ });
36
+ return changed ? out : value;
37
+ }
38
+ if (value && typeof value === 'object') {
39
+ if (THINKING.has(value.type)) return value;
40
+ let changed = false;
41
+ const out = {};
42
+ for (const [k, v] of Object.entries(value)) {
43
+ // Keys too: file-history snapshots are maps keyed by absolute path.
44
+ const nk = fn(k);
45
+ const m = mapStrings(v, fn);
46
+ if (m !== v || nk !== k) changed = true;
47
+ out[nk] = m;
48
+ }
49
+ return changed ? out : value;
50
+ }
51
+ return value;
52
+ }
53
+
54
+ /** Build a string rewriter from a list of [from, to] prefix pairs, applied in order of `from` length. */
55
+ function rewriter(pairs) {
56
+ const ordered = pairs
57
+ .filter(([from]) => from && from !== '/')
58
+ .sort((a, b) => b[0].length - a[0].length)
59
+ .map(([from, to]) => ({ from, to, re: prefixRe(from) }));
60
+ const stats = { replaced: 0 };
61
+ const fn = (s) => {
62
+ let out = s;
63
+ for (const { from, to, re } of ordered) {
64
+ if (!out.includes(from)) continue;
65
+ out = out.replace(re, () => {
66
+ stats.replaced++;
67
+ return to;
68
+ });
69
+ }
70
+ return out;
71
+ };
72
+ return { fn, stats };
73
+ }
74
+
75
+ /** `where` = {store, root, home}: absolute paths on the machine we are on. */
76
+ export function toPortable(where) {
77
+ return rewriter([
78
+ [where.store, TOKENS.store],
79
+ [where.root, TOKENS.root],
80
+ [where.home, TOKENS.home],
81
+ ]);
82
+ }
83
+
84
+ export function fromPortable(where) {
85
+ // Tokens are unambiguous, so a plain global replace is enough.
86
+ const pairs = [
87
+ [TOKENS.store, where.store],
88
+ [TOKENS.root, where.root],
89
+ [TOKENS.home, where.home],
90
+ ];
91
+ const stats = { replaced: 0 };
92
+ const fn = (s) => {
93
+ if (!s.includes('${CARRY_')) return s;
94
+ let out = s;
95
+ for (const [tok, abs] of pairs) {
96
+ if (!out.includes(tok)) continue;
97
+ out = out.split(tok).join(abs);
98
+ stats.replaced++;
99
+ }
100
+ return out;
101
+ };
102
+ return { fn, stats };
103
+ }
104
+
105
+ /** Distinct absolute home-style paths that survived tokenisation (a leak signal, reported to the user). */
106
+ export function residualHomePaths(strings) {
107
+ const found = new Set();
108
+ const re = /(?:\/Users|\/home)\/[^/\s"'`\\]+/g;
109
+ for (const s of strings) for (const m of s.matchAll(re)) found.add(m[0]);
110
+ return found;
111
+ }
package/src/redact.js ADDED
@@ -0,0 +1,79 @@
1
+ import { mapStrings } from './portable.js';
2
+
3
+ /**
4
+ * Secret scrubbing for text that is about to leave the machine. Deliberately
5
+ * pattern-based and conservative: it removes well-known credential shapes, it
6
+ * does not promise to find every secret. The export report says how many were
7
+ * removed so the user can judge, and `carry inspect` shows what a bundle holds.
8
+ */
9
+ const RULES = [
10
+ ['private-key', /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g, () => '[REDACTED:private-key]'],
11
+ ['anthropic-key', /sk-ant-[A-Za-z0-9_-]{20,}/g, () => '[REDACTED:anthropic-key]'],
12
+ ['openai-key', /\bsk-(?:proj-)?[A-Za-z0-9_-]{32,}/g, () => '[REDACTED:openai-key]'],
13
+ ['github-token', /\b(?:gh[pousr]_[A-Za-z0-9]{30,}|github_pat_[A-Za-z0-9_]{50,})/g, () => '[REDACTED:github-token]'],
14
+ ['aws-key', /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/g, () => '[REDACTED:aws-key]'],
15
+ ['google-key', /\bAIza[0-9A-Za-z_-]{35}\b/g, () => '[REDACTED:google-key]'],
16
+ ['slack-token', /\bxox[abprs]-[A-Za-z0-9-]{10,}/g, () => '[REDACTED:slack-token]'],
17
+ ['stripe-key', /\b[sr]k_(?:live|test)_[A-Za-z0-9]{16,}/g, () => '[REDACTED:stripe-key]'],
18
+ ['jwt', /\beyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g, () => '[REDACTED:jwt]'],
19
+ ['bearer', /(\bBearer\s+)[A-Za-z0-9._~+/-]{20,}=*/g, (_m, p) => `${p}[REDACTED:bearer]`],
20
+ ['url-credentials', /(\b[a-z][a-z0-9+.-]*:\/\/[^\s:/@]+:)[^\s@/]{3,}(@)/gi, (_m, a, b) => `${a}[REDACTED:password]${b}`],
21
+ [
22
+ 'env-secret',
23
+ // Name may follow an ANSI colour code directly ("\x1b[31mDB_PASSWORD=..."), where \b would not fire.
24
+ /(?:(?<![A-Za-z0-9_])|(?<=(?:\x1b|u001b)\[[0-9;]*m))([A-Za-z0-9_]*(?:SECRET|TOKEN|PASSWORD|PASSWD|API_?KEY|PRIVATE_KEY|ACCESS_KEY)[A-Za-z0-9_]*)(\s*[=:]\s*)(['"]?)(?!\[REDACTED)[^\s'"\\\x1b]{8,}\3/gi,
25
+ (m, name, sep, q, offset, whole) => {
26
+ // ALL_CAPS is env-var style: always redact. Mixed or lower case is often code (`max_tokens`,
27
+ // `token = parseToken(x)`), so there the value must look like a credential, not an identifier.
28
+ if (name !== name.toUpperCase()) {
29
+ const value = m.slice(name.length + sep.length + q.length, m.length - q.length);
30
+ if (/tokens|count|hash|limit|budget|usage|type|len/i.test(name)) return m;
31
+ if (!/[0-9]/.test(value) || /[()<>{}]/.test(value)) return m;
32
+ }
33
+ return `${name}${sep}${q}[REDACTED:env-secret]${q}`;
34
+ },
35
+ ],
36
+ [
37
+ 'json-secret',
38
+ // Tolerates any number of escaping backslashes: a JSON document stored inside a JSON string, and so on.
39
+ /(\\*"[A-Za-z_]*(?:secret|token|password|passwd|api_?key|private_?key)[A-Za-z_]*\\*"\s*:\s*\\*")(?!\[REDACTED)[^"\\]{8,}(\\*")/gi,
40
+ (_m, a, b) => `${a}[REDACTED:json-secret]${b}`,
41
+ ],
42
+ ];
43
+
44
+ /** Base64 blobs are only redacted when they decode to text that the rules above would redact. */
45
+ const looksPrintable = (t) => t.length > 0 && [...t].filter((c) => c >= ' ' && c <= '~' || c === '\n' || c === '\t').length / t.length > 0.95;
46
+ const BASE64 = [
47
+ 'base64-secret',
48
+ /(?<![A-Za-z0-9+/])[A-Za-z0-9+/]{24,}={0,2}(?![A-Za-z0-9+/=])/g,
49
+ (m) => {
50
+ let decoded;
51
+ try {
52
+ decoded = Buffer.from(m, 'base64').toString('utf8');
53
+ } catch {
54
+ return m;
55
+ }
56
+ if (!looksPrintable(decoded)) return m;
57
+ const changed = RULES.some(([, re, rep]) => decoded.replace(re, rep) !== decoded);
58
+ return changed ? '[REDACTED:base64-secret]' : m;
59
+ },
60
+ ];
61
+
62
+ const EMAIL = ['email', /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g, () => '[REDACTED:email]'];
63
+
64
+ export function makeRedactor({ emails = false } = {}) {
65
+ const rules = emails ? [...RULES, BASE64, EMAIL] : [...RULES, BASE64];
66
+ const counts = {};
67
+ const fn = (s) => {
68
+ let out = s;
69
+ for (const [kind, re, rep] of rules) {
70
+ out = out.replace(re, (...a) => {
71
+ const r = rep(...a);
72
+ if (r !== a[0]) counts[kind] = (counts[kind] || 0) + 1;
73
+ return r;
74
+ });
75
+ }
76
+ return out;
77
+ };
78
+ return { fn, counts, apply: (value) => mapStrings(value, fn) };
79
+ }
package/src/store.js ADDED
@@ -0,0 +1,84 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { projectsDir } from './paths.js';
4
+ import { parseJsonl, summarize } from './transcript.js';
5
+
6
+ const MAX_SIDECAR_BYTES = 20 * 1024 * 1024;
7
+
8
+ /** Every project folder under <home>/projects. */
9
+ export function projectFolders(home) {
10
+ const dir = projectsDir(home);
11
+ if (!fs.existsSync(dir)) return [];
12
+ return fs
13
+ .readdirSync(dir, { withFileTypes: true })
14
+ .filter((d) => d.isDirectory())
15
+ .map((d) => path.join(dir, d.name));
16
+ }
17
+
18
+ /** Sessions in one project folder, newest first. */
19
+ export function listSessions(folder) {
20
+ if (!fs.existsSync(folder)) return [];
21
+ return fs
22
+ .readdirSync(folder)
23
+ .filter((f) => f.endsWith('.jsonl'))
24
+ .map((f) => {
25
+ const file = path.join(folder, f);
26
+ const st = fs.statSync(file);
27
+ const s = summarize(parseJsonl(fs.readFileSync(file, 'utf8')));
28
+ return { id: f.slice(0, -'.jsonl'.length), file, folder, size: st.size, mtimeMs: st.mtimeMs, ...s };
29
+ })
30
+ .sort((a, b) => b.mtimeMs - a.mtimeMs);
31
+ }
32
+
33
+ /** Resolve a full id or unique prefix, searching `folders` in order. */
34
+ export function findSession(folders, idOrPrefix) {
35
+ const hits = [];
36
+ for (const folder of folders) {
37
+ if (!fs.existsSync(folder)) continue;
38
+ for (const f of fs.readdirSync(folder)) {
39
+ if (f.endsWith('.jsonl') && f.startsWith(idOrPrefix)) hits.push({ folder, id: f.slice(0, -6) });
40
+ }
41
+ }
42
+ if (hits.length === 0) throw new Error(`no session matches "${idOrPrefix}"`);
43
+ if (hits.length > 1) {
44
+ const where = hits.map((h) => `${h.id.slice(0, 8)} in ${path.basename(h.folder)}`).join('; ');
45
+ throw new Error(`"${idOrPrefix}" matches more than one session (${where}). Narrow it with --project DIR or a longer id.`);
46
+ }
47
+ return hits[0];
48
+ }
49
+
50
+ /**
51
+ * Everything that belongs to a session: its transcript plus the sidecar
52
+ * directory (`<id>/subagents/*.jsonl`, `<id>/tool-results/*`). Transcripts
53
+ * reference tool-results by absolute path, so they must travel together.
54
+ */
55
+ export function readSession(folder, id) {
56
+ const parts = [{ rel: 'main.jsonl', kind: 'jsonl', text: fs.readFileSync(path.join(folder, `${id}.jsonl`), 'utf8') }];
57
+ const skipped = [];
58
+ const sideDir = path.join(folder, id);
59
+ const walk = (dir, relBase) => {
60
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
61
+ const abs = path.join(dir, e.name);
62
+ const rel = path.posix.join(relBase, e.name);
63
+ if (e.isDirectory()) walk(abs, rel);
64
+ else if (e.isFile()) {
65
+ if (fs.statSync(abs).size > MAX_SIDECAR_BYTES) {
66
+ skipped.push(rel);
67
+ continue;
68
+ }
69
+ const buf = fs.readFileSync(abs);
70
+ if (rel.endsWith('.jsonl')) parts.push({ rel: `side/${rel}`, kind: 'jsonl', text: buf.toString('utf8') });
71
+ else if (isText(buf)) parts.push({ rel: `side/${rel}`, kind: 'text', text: buf.toString('utf8') });
72
+ else parts.push({ rel: `side/${rel}`, kind: 'binary', base64: buf.toString('base64') });
73
+ }
74
+ }
75
+ };
76
+ if (fs.existsSync(sideDir)) walk(sideDir, '');
77
+ return { parts, skipped };
78
+ }
79
+
80
+ function isText(buf) {
81
+ const n = Math.min(buf.length, 4096);
82
+ for (let i = 0; i < n; i++) if (buf[i] === 0) return false;
83
+ return true;
84
+ }
package/src/sync.js ADDED
@@ -0,0 +1,147 @@
1
+ import crypto from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { execFileSync } from 'node:child_process';
6
+ import { mangle, projectsDir, realpathOr } from './paths.js';
7
+ import { listSessions } from './store.js';
8
+ import { buildBundle, writeBundle, readBundle, importBundle } from './bundle.js';
9
+
10
+ /**
11
+ * Folder sync. `push` and `pull` turn any shared folder (Syncthing, Dropbox,
12
+ * iCloud, a git working tree, a network share) into a session sync backend:
13
+ *
14
+ * <dir>/<project>/<session-id>/<device-id>.carry.gz[.enc]
15
+ *
16
+ * Every file has exactly one writer (its device), so the sync tool never sees
17
+ * a write conflict. `pull` unions every other device's file into the local
18
+ * session, which is safe to repeat because merging is idempotent.
19
+ */
20
+
21
+ export function configDir(env = process.env) {
22
+ if (env.CARRY_HOME) return env.CARRY_HOME;
23
+ if (process.platform === 'win32') return path.join(env.APPDATA || os.homedir(), 'agent-carry');
24
+ return path.join(env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'), 'agent-carry');
25
+ }
26
+
27
+ const readJson = (f, d) => {
28
+ try {
29
+ return JSON.parse(fs.readFileSync(f, 'utf8'));
30
+ } catch {
31
+ return d;
32
+ }
33
+ };
34
+
35
+ function writeJson(f, v) {
36
+ fs.mkdirSync(path.dirname(f), { recursive: true });
37
+ const tmp = `${f}.tmp`;
38
+ fs.writeFileSync(tmp, JSON.stringify(v, null, 2));
39
+ fs.renameSync(tmp, f);
40
+ }
41
+
42
+ export function deviceInfo(cfg) {
43
+ const f = path.join(cfg, 'device.json');
44
+ let d = readJson(f, null);
45
+ if (!d?.id) {
46
+ const host = os.hostname().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 24) || 'device';
47
+ d = { id: `${host}-${crypto.randomBytes(3).toString('hex')}` };
48
+ writeJson(f, d);
49
+ }
50
+ return d;
51
+ }
52
+
53
+ /**
54
+ * The same project lives at different paths on different machines, so its
55
+ * identity can't be its path. Prefer the git remote; fall back to the folder name.
56
+ */
57
+ export function projectKey(root, override) {
58
+ const clean = (s) => s.toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '');
59
+ if (override) return clean(override);
60
+ const base = clean(path.basename(root)) || 'project';
61
+ try {
62
+ const url = execFileSync('git', ['-C', root, 'config', '--get', 'remote.origin.url'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
63
+ if (url) {
64
+ const norm = url.replace(/^[a-z+]+:\/\//i, '').replace(/^[^@/]+@/, '').replace(/:/g, '/').replace(/\.git$/, '').replace(/\/+$/, '').toLowerCase();
65
+ return `${base}-${crypto.createHash('sha256').update(norm).digest('hex').slice(0, 8)}`;
66
+ }
67
+ } catch {
68
+ /* not a git repo */
69
+ }
70
+ return base;
71
+ }
72
+
73
+ const ext = (passphrase) => (passphrase ? '.carry.enc' : '.carry.gz');
74
+
75
+ export function pushProject({ dir, claudeHome, root, userHome, passphrase, name, cfg = configDir(), log = () => {} }) {
76
+ const realRoot = realpathOr(root);
77
+ const folder = path.join(projectsDir(claudeHome), mangle(realRoot));
78
+ const key = projectKey(realRoot, name);
79
+ const dev = deviceInfo(cfg);
80
+ const out = { key, device: dev.id, pushed: 0, unchanged: 0, skipped: [] };
81
+ for (const s of listSessions(folder)) {
82
+ const target = path.join(dir, key, s.id, `${dev.id}${ext(passphrase)}`);
83
+ let built;
84
+ try {
85
+ built = buildBundle({ folder, id: s.id, where: { store: folder, root: realRoot, home: userHome } });
86
+ } catch (e) {
87
+ if (e.code !== 'THINKING_SECRETS') throw e;
88
+ out.skipped.push(s.id);
89
+ log(`skipped ${s.id.slice(0, 8)}: credential-like text in thinking blocks (export it by hand with --strip-thinking)`);
90
+ continue;
91
+ }
92
+ const { text, header } = built;
93
+ if (fs.existsSync(target)) {
94
+ try {
95
+ if (readBundle(target, { passphrase }).header.bodySha256 === header.bodySha256) {
96
+ out.unchanged++;
97
+ continue;
98
+ }
99
+ } catch {
100
+ /* unreadable old file: overwrite it */
101
+ }
102
+ }
103
+ writeBundle(target, text, { passphrase });
104
+ out.pushed++;
105
+ log(`pushed ${s.id.slice(0, 8)} ${s.title ?? ''}`);
106
+ }
107
+ return out;
108
+ }
109
+
110
+ export function pullProject({ dir, claudeHome, root, userHome, passphrase, name, dryRun = false, cfg = configDir(), log = () => {} }) {
111
+ const realRoot = realpathOr(root);
112
+ const key = projectKey(realRoot, name);
113
+ const dev = deviceInfo(cfg);
114
+ const stateFile = path.join(cfg, 'pulled.json');
115
+ const state = readJson(stateFile, {});
116
+ const out = { key, imported: 0, upToDate: 0, sessions: 0, errors: [] };
117
+ const projDir = path.join(dir, key);
118
+ if (!fs.existsSync(projDir)) return out;
119
+ for (const id of fs.readdirSync(projDir)) {
120
+ const sdir = path.join(projDir, id);
121
+ if (!fs.statSync(sdir).isDirectory()) continue;
122
+ for (const f of fs.readdirSync(sdir)) {
123
+ if (!/\.carry\.(gz|enc)$/.test(f) || f.startsWith(`${dev.id}.`)) continue;
124
+ const file = path.join(sdir, f);
125
+ const st = fs.statSync(file);
126
+ const sig = `${st.size}:${Math.round(st.mtimeMs)}`;
127
+ if (state[file] === sig) {
128
+ out.upToDate++;
129
+ continue;
130
+ }
131
+ try {
132
+ const bundle = readBundle(file, { passphrase });
133
+ const plan = importBundle({ bundle, claudeHome, root: realRoot, home: userHome, dryRun });
134
+ const added = plan.files.filter((x) => x.kind === 'jsonl').reduce((n, x) => n + x.added, 0);
135
+ if (!dryRun) state[file] = sig;
136
+ out.imported += added > 0 ? 1 : 0;
137
+ out.upToDate += added > 0 ? 0 : 1;
138
+ out.sessions++;
139
+ if (added > 0) log(`pulled ${id.slice(0, 8)} from ${f.split('.')[0]} (+${added} records)`);
140
+ } catch (e) {
141
+ out.errors.push(`${id.slice(0, 8)}/${f}: ${e.message}`);
142
+ }
143
+ }
144
+ }
145
+ if (!dryRun) writeJson(stateFile, state);
146
+ return out;
147
+ }
@@ -0,0 +1,66 @@
1
+ import crypto from 'node:crypto';
2
+
3
+ /**
4
+ * Transcripts are internal and undocumented, so parsing is tolerant: a line
5
+ * that is not JSON is kept verbatim and travels with the session untouched.
6
+ */
7
+ export function parseJsonl(text) {
8
+ const out = [];
9
+ for (const raw of text.split('\n')) {
10
+ if (raw === '') continue;
11
+ try {
12
+ out.push({ raw, rec: JSON.parse(raw) });
13
+ } catch {
14
+ out.push({ raw, rec: null });
15
+ }
16
+ }
17
+ return out;
18
+ }
19
+
20
+ const sha = (s) => crypto.createHash('sha256').update(s).digest('hex');
21
+ export const sha256 = sha;
22
+
23
+ /**
24
+ * Identity of a record for merging. Conversation records carry a `uuid`, which
25
+ * is stable across machines. Bookkeeping records (titles, modes, snapshots)
26
+ * have none, so they are identified by content.
27
+ */
28
+ export function recordKey({ raw, rec }) {
29
+ if (rec && typeof rec.uuid === 'string') return `u:${rec.uuid}`;
30
+ return `h:${sha(rec ? JSON.stringify(rec) : raw)}`;
31
+ }
32
+
33
+ export function summarize(entries) {
34
+ const cwds = new Map();
35
+ const versions = new Set();
36
+ const entrypoints = new Set();
37
+ let title = null;
38
+ let customTitle = null;
39
+ let first = null;
40
+ let last = null;
41
+ let turns = 0;
42
+ for (const { rec } of entries) {
43
+ if (!rec) continue;
44
+ if (rec.cwd) cwds.set(rec.cwd, (cwds.get(rec.cwd) || 0) + 1);
45
+ if (rec.version) versions.add(rec.version);
46
+ if (rec.entrypoint) entrypoints.add(rec.entrypoint);
47
+ if (rec.type === 'ai-title' && rec.aiTitle) title = rec.aiTitle;
48
+ if (rec.type === 'custom-title' && rec.customTitle) customTitle = rec.customTitle;
49
+ if (rec.timestamp) {
50
+ first ??= rec.timestamp;
51
+ last = rec.timestamp;
52
+ }
53
+ if (rec.type === 'user' && !rec.isMeta && !rec.isSidechain) turns++;
54
+ }
55
+ const dominantCwd = [...cwds.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] ?? null;
56
+ return {
57
+ title: customTitle || title,
58
+ dominantCwd,
59
+ cwds: [...cwds.keys()],
60
+ versions: [...versions],
61
+ entrypoints: [...entrypoints],
62
+ first,
63
+ last,
64
+ turns,
65
+ };
66
+ }