agent-carry 0.1.0 → 0.2.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 +13 -0
- package/README.md +70 -126
- package/package.json +3 -2
- package/plugin/.claude-plugin/plugin.json +2 -2
- package/plugin/commands/pickup.md +27 -8
- package/plugin/commands/share.md +22 -8
- package/src/bundle.js +6 -2
- package/src/cli.js +80 -7
- package/src/desktop.js +191 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## Unreleased
|
|
4
|
+
|
|
5
|
+
- Plugin 0.2.0: `/carry:share` and `/carry:pickup` ask what they need (session, encryption, passphrase source, destination folder, desktop app and account) instead of expecting flags. Needs the CLI from this release.
|
|
6
|
+
- `export --generate-passphrase` (saved to `FILE.pass`, never printed); `carry bundles`; `carry desktop-dirs`.
|
|
7
|
+
- `--desktop` targets the signed-in account (read from the app's log) instead of any folder with sessions, and works for a freshly signed-in account by borrowing only app-level settings.
|
|
8
|
+
|
|
9
|
+
- Plugin installable from this repo: `/plugin install carry --marketplace harshhh28/agent-carry`.
|
|
10
|
+
- `export --latest` skips sessions with no prompts, so an opened-and-closed session no longer shadows the real one.
|
|
11
|
+
- `--desktop` strips terminal colour codes from newly imported text, which the app shows as literal characters.
|
|
12
|
+
- `carry import --desktop` adds the session to the Claude desktop app's Code tab (macOS).
|
|
13
|
+
- `agent-carry` works as a second command name for `carry`.
|
|
14
|
+
- README trimmed; plugin author set.
|
|
15
|
+
|
|
3
16
|
## 0.1.0
|
|
4
17
|
|
|
5
18
|
First release. Claude Code adapter.
|
package/README.md
CHANGED
|
@@ -1,156 +1,100 @@
|
|
|
1
1
|
# agent-carry
|
|
2
2
|
|
|
3
|
-
Move a coding-agent session to another machine
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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.
|
|
3
|
+
Move a coding-agent session to another machine or folder and keep working. Supports Claude Code today.
|
|
4
|
+
|
|
5
|
+
Claude Code stores sessions per absolute project path, and the transcripts are full of absolute paths, so a
|
|
6
|
+
copied session can't be resumed elsewhere. `carry` fixes that:
|
|
7
|
+
|
|
8
|
+
- **Path-portable:** project, home and store paths become tokens on export and are rewritten on import.
|
|
9
|
+
- **Merge-safe:** import is a union by record `uuid`. Importing twice changes nothing; a session continued on two machines becomes a two-branch history.
|
|
10
|
+
- **Redacted:** credential-shaped strings (cloud keys, tokens, JWTs, private keys, `*_SECRET=...`, URL passwords) are removed on export, with a report.
|
|
11
|
+
- **Complete:** subagent transcripts and tool results travel with the session.
|
|
12
|
+
- **Inspectable:** a bundle is newline-delimited JSON (gzipped).
|
|
13
|
+
|
|
14
|
+
## Usage
|
|
26
15
|
|
|
27
16
|
```
|
|
28
|
-
npm i -g agent-carry
|
|
17
|
+
npm i -g agent-carry # installs `carry` (also available as `agent-carry`)
|
|
29
18
|
|
|
30
|
-
#
|
|
19
|
+
# machine A, in the project folder
|
|
31
20
|
carry list
|
|
32
21
|
carry export --latest -o session.carry.gz
|
|
33
22
|
|
|
34
|
-
#
|
|
23
|
+
# machine B, in the same project (any path)
|
|
35
24
|
carry import session.carry.gz
|
|
36
|
-
claude --resume <id>
|
|
25
|
+
claude --resume <id> # printed by import
|
|
37
26
|
```
|
|
38
27
|
|
|
39
|
-
|
|
28
|
+
Encrypt a bundle with `CARRY_PASSPHRASE` or `--passphrase-file FILE` (AES-256-GCM, scrypt key). Never pass the
|
|
29
|
+
passphrase as an argument.
|
|
40
30
|
|
|
41
|
-
###
|
|
31
|
+
### Claude desktop app (Code tab)
|
|
42
32
|
|
|
43
|
-
|
|
44
|
-
|
|
33
|
+
The Code tab lists sessions from its own index, so an imported session resumes in a terminal but doesn't appear
|
|
34
|
+
there. Add `--desktop` to also write the index entry (macOS; the signed-in account is used):
|
|
45
35
|
|
|
46
36
|
```
|
|
47
|
-
|
|
48
|
-
carry push ~/Sync/carry # this project's sessions -> folder
|
|
49
|
-
carry pull ~/Sync/carry # other devices' sessions -> this machine
|
|
37
|
+
carry import session.carry.gz --desktop
|
|
50
38
|
```
|
|
51
39
|
|
|
52
|
-
|
|
53
|
-
|
|
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.
|
|
40
|
+
App settings are copied from that account's newest Code-tab session (or, for a new account, another account's), so use the Code tab at least once first.
|
|
41
|
+
Terminal colour codes in the imported text are stripped (the app would show them as literal `[38;2;…m`). Restart the desktop app if the session doesn't show.
|
|
56
42
|
|
|
57
|
-
|
|
58
|
-
end. (Hooks are written to the plugin spec but I have not exercised them inside a live Claude Code yet.)
|
|
43
|
+
The session is added to the account that is signed in (read from the app's log). If it can't tell, or you want another account, use `--desktop-dir DIR`; `carry desktop-dirs` lists the choices. A freshly signed-in account with no sessions borrows only app-level settings from another account, never its connectors or permissions. Cowork is a different store and isn't supported.
|
|
59
44
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
|
45
|
+
### Sync through a shared folder
|
|
46
|
+
|
|
47
|
+
Point both machines at a folder your sync tool already replicates (Dropbox, iCloud, Syncthing, a git tree).
|
|
48
|
+
|
|
49
|
+
```
|
|
50
|
+
carry push ~/Sync/carry # this project's sessions -> folder
|
|
51
|
+
carry pull ~/Sync/carry # other devices' sessions -> this machine
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Projects are matched by git remote, not path. Each file has one writer, so there are no sync conflicts.
|
|
55
|
+
|
|
56
|
+
### Claude Code plugin
|
|
124
57
|
|
|
125
58
|
```
|
|
126
|
-
|
|
127
|
-
sh scripts/verify-resume.sh <session-id-prefix>
|
|
59
|
+
/plugin install carry --marketplace harshhh28/agent-carry
|
|
128
60
|
```
|
|
129
61
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
62
|
+
Needs `npm i -g agent-carry` (or falls back to `npx`). Works the same in the terminal and in the desktop app's Code tab.
|
|
63
|
+
|
|
64
|
+
- `/carry:share` asks which session, whether to encrypt (generate a passphrase, use your own file, or type one), and where to save.
|
|
65
|
+
- `/carry:pickup` lists bundles in this folder, ~/Downloads and ~/Desktop, then asks which one, the passphrase source, which folder to import into, and whether to add it to the desktop app (and which account).
|
|
66
|
+
|
|
67
|
+
Passphrases are never put on a command line. A generated one is saved beside the bundle as `FILE.pass` (mode 600) and never printed; a typed one is stored in that conversation's history, so use it only for throwaway passphrases. With `CARRY_SYNC_DIR` set, bundled hooks pull on session start and push on session end (hooks not yet exercised live).
|
|
68
|
+
|
|
69
|
+
## Commands
|
|
133
70
|
|
|
134
|
-
|
|
71
|
+
| command | |
|
|
72
|
+
|---|---|
|
|
73
|
+
| `carry list [--all]` | sessions for this folder, or all |
|
|
74
|
+
| `carry export <id\|--latest> [-o FILE]` | `--generate-passphrase`, `--no-redact`, `--redact-emails`, `--strip-thinking`, `--allow-thinking-secrets`, `--project-root DIR` |
|
|
75
|
+
| `carry inspect FILE` | header, parts, redaction counts |
|
|
76
|
+
| `carry import FILE [--project-root DIR] [--dry-run]` | write into the target folder's session store |
|
|
77
|
+
| `carry bundles` / `carry desktop-dirs` | bundle files nearby; desktop-app account folders |
|
|
78
|
+
| `carry push DIR` / `carry pull DIR` | folder sync (`--name`, `--dry-run`, `--quiet`) |
|
|
135
79
|
|
|
136
|
-
|
|
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.
|
|
80
|
+
## Limits
|
|
141
81
|
|
|
142
|
-
|
|
82
|
+
- **Resume** is verified on one real session (Claude Code 2.1.299); the transcript format is internal and can change. Run `scripts/verify-resume.sh <id>` to check yours.
|
|
83
|
+
- **Thinking blocks are signed**, so they travel verbatim. If one contains a credential-like string, export refuses unless you pass `--strip-thinking` (resume with stripped thinking is untested) or `--allow-thinking-secrets`. `push` skips such sessions.
|
|
84
|
+
- **Redaction is best effort.** It misses passwords that read like sentences and letters-only lowercase `password=word`. Read `carry inspect` before sharing, and encrypt anything you send to someone else.
|
|
85
|
+
- `--desktop` writes the app's index format, which is internal and undocumented; it is tested against fixtures and a dry run on a real Code-tab folder, but not yet confirmed by resuming from the app itself.
|
|
86
|
+
- Not handled: Windows paths, Cowork, file-history (rewind) backups.
|
|
87
|
+
- `push` re-uploads pulled sessions, so a sync folder grows with devices × sessions.
|
|
88
|
+
|
|
89
|
+
## Development
|
|
143
90
|
|
|
144
91
|
```
|
|
145
|
-
|
|
146
|
-
|
|
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
|
|
92
|
+
npm test # 49 tests, synthetic two-machine setups
|
|
93
|
+
npm run check:real # read-only round trip of your own ~/.claude sessions
|
|
154
94
|
```
|
|
155
95
|
|
|
156
|
-
|
|
96
|
+
To add an agent, supply its session-folder mapping (`src/paths.js`), session reader (`src/store.js`), transcript
|
|
97
|
+
record identity (`src/transcript.js`) and the `AGENT` constant (`src/bundle.js`). Path rewriting, redaction,
|
|
98
|
+
merging, encryption and sync are agent-independent. Codex is not implemented yet.
|
|
99
|
+
|
|
100
|
+
MIT
|
package/package.json
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-carry",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
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
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
|
-
"carry": "src/cli.js"
|
|
7
|
+
"carry": "src/cli.js",
|
|
8
|
+
"agent-carry": "src/cli.js"
|
|
8
9
|
},
|
|
9
10
|
"files": [
|
|
10
11
|
"src",
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "carry",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
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": "
|
|
5
|
+
"author": { "name": "Harsh Gajjar", "email": "harshgajjar2812@gmail.com" },
|
|
6
6
|
"license": "MIT"
|
|
7
7
|
}
|
|
@@ -1,13 +1,32 @@
|
|
|
1
1
|
---
|
|
2
|
-
description: Import a carry bundle into
|
|
3
|
-
argument-hint:
|
|
4
|
-
allowed-tools: Bash(carry
|
|
2
|
+
description: Import a carry bundle into a folder (and optionally the Claude desktop app); asks which bundle, passphrase, and where
|
|
3
|
+
argument-hint: [bundle-file]
|
|
4
|
+
allowed-tools: Bash(carry:*), Bash(npx -y agent-carry@latest:*), Bash(ls:*), Bash(pwd), AskUserQuestion
|
|
5
5
|
---
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
Bundle files nearby (this folder, ~/Downloads, ~/Desktop), newest first:
|
|
8
8
|
|
|
9
|
-
!`carry
|
|
9
|
+
!`carry bundles 2>&1 || npx -y agent-carry@latest bundles 2>&1`
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
11
|
+
Current folder: !`pwd`
|
|
12
|
+
|
|
13
|
+
Claude desktop app account folders (macOS only; an error here just means no desktop app):
|
|
14
|
+
|
|
15
|
+
!`carry desktop-dirs 2>&1`
|
|
16
|
+
|
|
17
|
+
Arguments the user typed: $ARGUMENTS
|
|
18
|
+
|
|
19
|
+
You are importing one session with the `carry` CLI. If `carry` was not found above, use `npx -y agent-carry@latest` in its place; if that also fails, tell the user to run `npm i -g agent-carry` and stop. If a command says it is unknown, the CLI is old: tell the user to run `npm i -g agent-carry@latest`.
|
|
20
|
+
|
|
21
|
+
Ask with the AskUserQuestion tool, one short question at a time, and skip any question the typed arguments already answer. If AskUserQuestion is not available, ask as a short numbered list in plain text and wait.
|
|
22
|
+
|
|
23
|
+
1. **Which bundle.** If the arguments name a file, use it. Otherwise offer up to four of the listed bundles (label: file name; description: date, size and what it holds). If none were found, ask for a path.
|
|
24
|
+
2. **Passphrase, only if the bundle is encrypted.** Options:
|
|
25
|
+
- "Use the .pass file next to it": offer only if `ls <bundle>.pass` shows it exists.
|
|
26
|
+
- "A passphrase file at another path": ask for the path, then `--passphrase-file PATH`.
|
|
27
|
+
- "I'll type it here": warn that it is stored in this conversation's history. Pass it as `CARRY_PASSPHRASE='…' carry import …` and never repeat it back.
|
|
28
|
+
(If `CARRY_PASSPHRASE` is already set in the environment, nothing is needed.) On a wrong-passphrase error, say so and ask once more.
|
|
29
|
+
3. **Which folder.** Claude Code finds a session by the folder you run `claude` in, so this should be the folder they will work in. Offer the current folder as "(Recommended)" or another path (create it with `mkdir -p` only if the user says so). Pass it as `--project-root`.
|
|
30
|
+
4. **Claude desktop app (only if account folders are listed above).** Ask "Also show it in the Claude desktop app's Code tab?" If yes and more than one folder is listed, ask which account: the one marked "* signed in" is "(Recommended)"; the rest are listed by their folder name and session count. Use `--desktop` for the signed-in account, or `--desktop-dir <folder>` for another one. If the import says it cannot tell which account is signed in, ask the user to choose from the list it printed.
|
|
31
|
+
5. **Run** `carry import <bundle> --project-root <folder> [--passphrase-file F] [--desktop | --desktop-dir D]` (add `--dry-run` if the user typed it, and say nothing was written).
|
|
32
|
+
6. **Report in at most five lines:** the session id, the folder it went into, and any warning. Then the next step: exit this session, then `cd <folder> && claude --resume <id>`; for the desktop app, restart it if the session does not appear and open the Code tab. A running session cannot swap its own history, so do not run `claude --resume` yourself.
|
package/plugin/commands/share.md
CHANGED
|
@@ -1,13 +1,27 @@
|
|
|
1
1
|
---
|
|
2
|
-
description: Export
|
|
3
|
-
argument-hint: [-o FILE
|
|
4
|
-
allowed-tools: Bash(carry
|
|
2
|
+
description: Export a session as a portable, redacted bundle; asks which session, whether to encrypt, and where to save
|
|
3
|
+
argument-hint: [optional carry export flags, e.g. -o FILE --strip-thinking]
|
|
4
|
+
allowed-tools: Bash(carry:*), Bash(npx -y agent-carry@latest:*), AskUserQuestion
|
|
5
5
|
---
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
Sessions in this folder, newest first (the top one is usually this conversation):
|
|
8
8
|
|
|
9
|
-
!`carry
|
|
9
|
+
!`carry list 2>&1 || npx -y agent-carry@latest list 2>&1`
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
11
|
+
Arguments the user typed: $ARGUMENTS
|
|
12
|
+
|
|
13
|
+
You are exporting one session with the `carry` CLI. If `carry` was not found above, use `npx -y agent-carry@latest` in its place; if that also fails, tell the user to run `npm i -g agent-carry` and stop. If a command below says it is unknown, the CLI is old: tell the user to run `npm i -g agent-carry@latest`.
|
|
14
|
+
|
|
15
|
+
Ask with the AskUserQuestion tool, one short question at a time, and skip any question the typed arguments already answer. If AskUserQuestion is not available, ask the same questions as a short numbered list in plain text and wait for the reply.
|
|
16
|
+
|
|
17
|
+
1. **Which session.** Ignore rows with 0 turns. If only one has turns, use it without asking. Otherwise ask, offering the newest first as "(Recommended)" and up to three more; label each with its id prefix and title, and describe it with its date and turn count.
|
|
18
|
+
2. **Encrypt?** Options:
|
|
19
|
+
- "No, plain file": readable by anyone who gets it (secrets are still redacted).
|
|
20
|
+
- "Yes, generate a passphrase (Recommended when sending it to someone)": run with `--generate-passphrase`.
|
|
21
|
+
- "Yes, use my passphrase file": ask for the path, then use `--passphrase-file PATH`.
|
|
22
|
+
- "Yes, I'll type one here": warn that whatever is typed is stored in this conversation's history, so use only a throwaway passphrase. Take it from a follow-up question and pass it as `CARRY_PASSPHRASE='…' carry export …`. Never repeat it back, never put it in a file name.
|
|
23
|
+
3. **Where to save.** Options: this folder (default), Desktop, or another path. Name the file `<first 8 chars of the id>.carry.gz`, or `.carry.enc` when encrypting, and pass it with `-o`.
|
|
24
|
+
4. **Run** `carry export <id> -o <file> [flags]` (with `--project-root` only if the user asked). If it refuses because signed thinking blocks contain credential-like strings, ask: "Drop thinking blocks (Recommended)" or "Keep them as they are (the strings stay in the file)", then re-run with `--strip-thinking` or `--allow-thinking-secrets`.
|
|
25
|
+
5. **Report in at most five lines:** the file path, how many secrets were redacted, whether it is encrypted, and any warning. If a passphrase was generated, say it is saved in `<file>.pass` (mode 600) and must be sent by a different channel than the bundle; do not print it. Tell them the other side runs `/carry:pickup` or `carry import <file>`.
|
|
26
|
+
|
|
27
|
+
Never open or print the bundle's contents, and never put a passphrase on a command line other than as `CARRY_PASSPHRASE` above.
|
package/src/bundle.js
CHANGED
|
@@ -170,11 +170,15 @@ export function readBundle(file, { passphrase } = {}) {
|
|
|
170
170
|
* Write a bundle into a Claude home for a target project root. Returns a plan;
|
|
171
171
|
* with dryRun it writes nothing.
|
|
172
172
|
*/
|
|
173
|
-
|
|
173
|
+
const ANSI = /\x1b\[[0-9;]*[A-Za-z]/g;
|
|
174
|
+
|
|
175
|
+
/** `stripAnsi` drops terminal colour codes from incoming text, for apps that show them as literal characters. */
|
|
176
|
+
export function importBundle({ bundle, claudeHome, root, home, dryRun = false, stripAnsi = false }) {
|
|
174
177
|
const realRoot = realpathOr(root);
|
|
175
178
|
const folder = path.join(projectsDir(claudeHome), mangle(realRoot));
|
|
176
179
|
const where = { store: folder, root: realRoot, home };
|
|
177
180
|
const back = fromPortable(where);
|
|
181
|
+
const text = stripAnsi ? (v) => back.fn(v).replace(ANSI, '') : back.fn;
|
|
178
182
|
const id = bundle.header.sessionId;
|
|
179
183
|
const plan = { id, folder, root: realRoot, files: [], warnings: [] };
|
|
180
184
|
if (mangle(realRoot).length > 200) {
|
|
@@ -195,7 +199,7 @@ export function importBundle({ bundle, claudeHome, root, home, dryRun = false })
|
|
|
195
199
|
let stat;
|
|
196
200
|
if (kind === 'jsonl') {
|
|
197
201
|
const incoming = rows.map((r) => {
|
|
198
|
-
const raw = r.l !== undefined ? JSON.stringify(mapStrings(r.l,
|
|
202
|
+
const raw = r.l !== undefined ? JSON.stringify(mapStrings(r.l, text)) : back.fn(r.x);
|
|
199
203
|
return { raw, rec: r.l !== undefined ? JSON.parse(raw) : null };
|
|
200
204
|
});
|
|
201
205
|
const local = fs.existsSync(dest) ? parseJsonl(fs.readFileSync(dest, 'utf8')) : [];
|
package/src/cli.js
CHANGED
|
@@ -6,16 +6,22 @@ import { projectFolders, listSessions, findSession } from './store.js';
|
|
|
6
6
|
import { buildBundle, writeBundle, readBundle, importBundle } from './bundle.js';
|
|
7
7
|
import { parseJsonl, summarize } from './transcript.js';
|
|
8
8
|
import fs from 'node:fs';
|
|
9
|
+
import crypto from 'node:crypto';
|
|
10
|
+
import { isEncrypted } from './crypto.js';
|
|
9
11
|
import { pushProject, pullProject } from './sync.js';
|
|
12
|
+
import { registerDesktopSession, listDesktopDirs } from './desktop.js';
|
|
10
13
|
|
|
11
14
|
const HELP = `carry (agent-carry): move coding-agent sessions between machines and folders. Supported agent: claude-code
|
|
12
15
|
|
|
13
16
|
carry list [--all] [--project DIR]
|
|
14
17
|
carry export <id|prefix|--latest> [-o FILE] [--project-root DIR]
|
|
15
18
|
[--no-redact] [--redact-emails] [--strip-thinking | --allow-thinking-secrets]
|
|
19
|
+
[--generate-passphrase] encrypt with a random passphrase saved next to the bundle as FILE.pass
|
|
20
|
+
carry bundles bundle files in this folder, ~/Downloads and ~/Desktop
|
|
16
21
|
carry inspect <bundle>
|
|
17
|
-
carry import <bundle> [--project-root DIR] [--dry-run]
|
|
22
|
+
carry import <bundle> [--project-root DIR] [--dry-run] [--desktop [--desktop-dir DIR]]
|
|
18
23
|
carry push <dir> [--name PROJECT] [--quiet] sync every session of this project to a shared folder
|
|
24
|
+
carry desktop-dirs desktop-app account folders (which one is signed in)
|
|
19
25
|
carry pull <dir> [--name PROJECT] [--dry-run] [--quiet]
|
|
20
26
|
|
|
21
27
|
Encryption (export, import, push, pull): set CARRY_PASSPHRASE or pass --passphrase-file FILE.
|
|
@@ -26,7 +32,7 @@ Global: --claude-home DIR (default $CLAUDE_CONFIG_DIR or ~/.claude), --home DIR
|
|
|
26
32
|
function parseArgs(argv) {
|
|
27
33
|
const flags = {};
|
|
28
34
|
const pos = [];
|
|
29
|
-
const valued = new Set(['-o', '--out', '--project', '--project-root', '--claude-home', '--home', '--name', '--passphrase-file']);
|
|
35
|
+
const valued = new Set(['-o', '--out', '--project', '--project-root', '--claude-home', '--home', '--name', '--passphrase-file', '--desktop-dir']);
|
|
30
36
|
for (let i = 0; i < argv.length; i++) {
|
|
31
37
|
const a = argv[i];
|
|
32
38
|
if (a.startsWith('-') && a !== '-') {
|
|
@@ -52,7 +58,7 @@ function main() {
|
|
|
52
58
|
const home = flags['claude-home'] ? path.resolve(flags['claude-home']) : claudeHome();
|
|
53
59
|
const userHome = flags.home ? path.resolve(flags.home) : os.homedir();
|
|
54
60
|
const cwd = flags['project-root'] || flags.project || process.cwd();
|
|
55
|
-
|
|
61
|
+
let passphrase = flags['passphrase-file'] ? fs.readFileSync(flags['passphrase-file'], 'utf8').replace(/\r?\n$/, '') : process.env.CARRY_PASSPHRASE || undefined;
|
|
56
62
|
const say = (m) => flags.quiet || process.stdout.write(`${m}\n`);
|
|
57
63
|
|
|
58
64
|
try {
|
|
@@ -78,8 +84,12 @@ function main() {
|
|
|
78
84
|
let target;
|
|
79
85
|
if (flags.latest) {
|
|
80
86
|
const here = path.join(projectsDir(home), mangle(realpathOr(cwd)));
|
|
81
|
-
const
|
|
82
|
-
if (!
|
|
87
|
+
const all = listSessions(here);
|
|
88
|
+
if (!all.length) fail(`no sessions for ${realpathOr(cwd)}`);
|
|
89
|
+
// A session opened and closed without a prompt has nothing to carry; don't let it shadow the real one.
|
|
90
|
+
const s = all.find((x) => x.turns > 0);
|
|
91
|
+
if (!s) fail(`every session for ${realpathOr(cwd)} is empty (no prompts yet); run \`carry list\``);
|
|
92
|
+
if (s !== all[0]) process.stderr.write(`carry: skipped ${all.indexOf(s)} newer empty session(s); exporting ${s.id.slice(0, 8)}\n`);
|
|
83
93
|
target = { folder: here, id: s.id };
|
|
84
94
|
} else {
|
|
85
95
|
if (!pos[0]) fail('export needs a session id (see `carry list`) or --latest');
|
|
@@ -106,13 +116,21 @@ function main() {
|
|
|
106
116
|
stripThinking: !!flags['strip-thinking'],
|
|
107
117
|
allowThinkingSecrets: !!flags['allow-thinking-secrets'],
|
|
108
118
|
});
|
|
119
|
+
let generated = null;
|
|
120
|
+
if (flags['generate-passphrase']) {
|
|
121
|
+
if (passphrase) fail('use either --generate-passphrase or a passphrase of your own, not both');
|
|
122
|
+
generated = crypto.randomBytes(18).toString('base64url');
|
|
123
|
+
passphrase = generated;
|
|
124
|
+
}
|
|
109
125
|
const out = flags.o || flags.out || `${target.id.slice(0, 8)}${passphrase ? '.carry.enc' : '.carry.gz'}`;
|
|
110
126
|
writeBundle(out, text, { passphrase });
|
|
127
|
+
if (generated) fs.writeFileSync(`${out}.pass`, `${generated}\n`, { mode: 0o600 });
|
|
111
128
|
const red = Object.entries(header.redaction.counts || {}).map(([k, v]) => `${k}:${v}`).join(' ') || 'none found';
|
|
112
129
|
process.stdout.write(
|
|
113
130
|
`exported ${header.sessionId.slice(0, 8)} "${header.title ?? 'untitled'}" -> ${out}\n` +
|
|
114
131
|
` parts: ${header.parts.length} paths rewritten: ${header.pathsRewritten}\n` +
|
|
115
|
-
` redacted: ${header.redaction.enabled ? red : 'OFF'} encrypted: ${passphrase ? 'yes' : 'no'}\n
|
|
132
|
+
` redacted: ${header.redaction.enabled ? red : 'OFF'} encrypted: ${passphrase ? 'yes' : 'no'}\n` +
|
|
133
|
+
(generated ? ` passphrase saved to ${out}.pass (not printed); share it by a different channel than the bundle\n` : ''),
|
|
116
134
|
);
|
|
117
135
|
if (header.residualHomePaths > 0) {
|
|
118
136
|
process.stderr.write(` warning: ${header.residualHomePaths} other absolute home path(s) remain in the bundle (other users or tools). Review with \`carry inspect\`.\n`);
|
|
@@ -124,6 +142,48 @@ function main() {
|
|
|
124
142
|
return;
|
|
125
143
|
}
|
|
126
144
|
|
|
145
|
+
if (cmd === 'bundles') {
|
|
146
|
+
const seen = new Set();
|
|
147
|
+
const found = [];
|
|
148
|
+
for (const dir of [process.cwd(), path.join(userHome, 'Downloads'), path.join(userHome, 'Desktop')]) {
|
|
149
|
+
let names = [];
|
|
150
|
+
try {
|
|
151
|
+
names = fs.readdirSync(dir);
|
|
152
|
+
} catch {
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
for (const n of names.filter((n) => /\.carry\.(gz|enc)$/.test(n))) {
|
|
156
|
+
const file = path.join(dir, n);
|
|
157
|
+
if (seen.has(file)) continue;
|
|
158
|
+
seen.add(file);
|
|
159
|
+
found.push({ file, st: fs.statSync(file) });
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
found.sort((a, b) => b.st.mtimeMs - a.st.mtimeMs);
|
|
163
|
+
for (const { file, st } of found.slice(0, 10)) {
|
|
164
|
+
const enc = isEncrypted(fs.readFileSync(file).subarray(0, 64));
|
|
165
|
+
let what = 'encrypted: needs its passphrase';
|
|
166
|
+
if (!enc) {
|
|
167
|
+
try {
|
|
168
|
+
const { header } = readBundle(file, {});
|
|
169
|
+
what = `"${header.title ?? 'untitled'}", ${header.turns} turn(s), session ${header.sessionId.slice(0, 8)}`;
|
|
170
|
+
} catch {
|
|
171
|
+
what = 'unreadable';
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
process.stdout.write(`${file}\n ${enc ? 'encrypted' : 'plain'} ${st.mtime.toISOString().slice(0, 16).replace('T', ' ')} ${(st.size / 1024).toFixed(1)} KB ${what}\n`);
|
|
175
|
+
}
|
|
176
|
+
if (!found.length) process.stdout.write('no .carry.gz or .carry.enc files found here, in ~/Downloads or ~/Desktop\n');
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (cmd === 'desktop-dirs') {
|
|
181
|
+
const list = listDesktopDirs({ home: userHome });
|
|
182
|
+
for (const x of list) process.stdout.write(`${x.active ? '* signed in' : ' other '} ${x.entries} session${x.entries === 1 ? ' ' : 's'} ${x.dir}\n`);
|
|
183
|
+
if (!list.length) process.stdout.write('no desktop account folders found\n');
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
|
|
127
187
|
if (cmd === 'inspect') {
|
|
128
188
|
if (!pos[0]) fail('inspect needs a bundle file');
|
|
129
189
|
const { header } = readBundle(pos[0], { passphrase });
|
|
@@ -134,7 +194,7 @@ function main() {
|
|
|
134
194
|
if (cmd === 'import') {
|
|
135
195
|
if (!pos[0]) fail('import needs a bundle file');
|
|
136
196
|
const bundle = readBundle(pos[0], { passphrase });
|
|
137
|
-
const plan = importBundle({ bundle, claudeHome: home, root: cwd, home: userHome, dryRun: !!flags['dry-run'] });
|
|
197
|
+
const plan = importBundle({ bundle, claudeHome: home, root: cwd, home: userHome, dryRun: !!flags['dry-run'], stripAnsi: !!(flags.desktop || flags['desktop-dir']) });
|
|
138
198
|
process.stdout.write(`${flags['dry-run'] ? 'would import' : 'imported'} ${plan.id.slice(0, 8)} into ${plan.folder}\n`);
|
|
139
199
|
for (const f of plan.files.filter((f) => f.kind === 'jsonl')) {
|
|
140
200
|
process.stdout.write(` ${f.rel}: +${f.added} new, ${f.skipped} already present\n`);
|
|
@@ -142,6 +202,19 @@ function main() {
|
|
|
142
202
|
const side = plan.files.filter((f) => f.kind !== 'jsonl');
|
|
143
203
|
if (side.length) process.stdout.write(` sidecar files: ${side.filter((f) => f.added).length} new, ${side.filter((f) => !f.added).length} kept\n`);
|
|
144
204
|
for (const w of plan.warnings) process.stderr.write(` warning: ${w}\n`);
|
|
205
|
+
if (flags.desktop || flags['desktop-dir']) {
|
|
206
|
+
try {
|
|
207
|
+
const main = plan.files.find((f) => f.rel === 'main.jsonl');
|
|
208
|
+
const d = registerDesktopSession({ id: plan.id, root: plan.root, mainFile: main.dest, home: userHome, dir: flags['desktop-dir'], dryRun: !!flags['dry-run'] });
|
|
209
|
+
const verb = { registered: 'added to the desktop Code tab', exists: 'already in the desktop Code tab', 'would-register': 'would be added to the desktop Code tab' }[d.status];
|
|
210
|
+
process.stdout.write(` ${verb} (${d.file}); restart the Claude desktop app if it does not appear\n`);
|
|
211
|
+
process.stdout.write(` desktop folder: ${d.dir}\n`);
|
|
212
|
+
if (d.status === 'exists' && d.entryCwd && d.entryCwd !== plan.root) process.stdout.write(` note: that Code-tab entry points at ${d.entryCwd}, not ${plan.root}\n`);
|
|
213
|
+
process.stdout.write(` chosen as: ${d.how}${d.borrowed ? '; this account has no sessions yet, so app settings were borrowed from another account' : ''}\n`);
|
|
214
|
+
} catch (e) {
|
|
215
|
+
process.stderr.write(` desktop: ${e.message}\n the session itself was imported and resumes from a terminal\n`);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
145
218
|
if (!flags['dry-run']) process.stdout.write(`\nresume with:\n cd ${plan.root} && claude --resume ${plan.id}\n`);
|
|
146
219
|
return;
|
|
147
220
|
}
|
package/src/desktop.js
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
import { parseJsonl, summarize } from './transcript.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The Claude desktop app's Code tab lists sessions from its own index, one
|
|
8
|
+
* `local_<uuid>.json` per session, whose `cliSessionId` points at the normal
|
|
9
|
+
* Claude Code transcript. Importing a transcript does not add it to that
|
|
10
|
+
* index, so it would resume in a terminal but not show up in the Code tab.
|
|
11
|
+
*
|
|
12
|
+
* This writes the missing index entry. App-level settings (MCP servers,
|
|
13
|
+
* prompt snapshot, ...) are copied from the newest existing entry rather than
|
|
14
|
+
* invented, so a Code tab that has never been used cannot be targeted.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
// Settings that belong to the app, not to an account: safe to borrow from a session of another account.
|
|
18
|
+
const GENERIC_KEYS = ['effort', 'classifierSummaryEnabled', 'reportFindingsCard', 'promptAppendSnapshot', 'chromePermissionMode'];
|
|
19
|
+
|
|
20
|
+
// Everything below also reflects one account's MCP connectors and tool toggles, so it is copied only from the same account.
|
|
21
|
+
const KEEP_FROM_TEMPLATE = [
|
|
22
|
+
'effort',
|
|
23
|
+
'permissionMode',
|
|
24
|
+
'enabledMcpTools',
|
|
25
|
+
'remoteMcpServersConfig',
|
|
26
|
+
'chromePermissionMode',
|
|
27
|
+
'classifierSummaryEnabled',
|
|
28
|
+
'reportFindingsCard',
|
|
29
|
+
'promptAppendSnapshot',
|
|
30
|
+
'toolSurfaceSnapshot',
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
export function desktopRoot(home, platform = process.platform) {
|
|
34
|
+
if (platform === 'darwin') return path.join(home, 'Library', 'Application Support', 'Claude', 'claude-code-sessions');
|
|
35
|
+
throw new Error('automatic desktop-app lookup is only implemented for macOS; pass --desktop-dir');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const subdirs = (d) => fs.readdirSync(d, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => path.join(d, e.name));
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The app's most recently active account/org, read from its own log: the app starts every session under
|
|
42
|
+
* scratch-workspaces/<account>/<org>/, so the last such path names whoever is signed in now.
|
|
43
|
+
*/
|
|
44
|
+
export function activeAccountFromLog(home, platform = process.platform) {
|
|
45
|
+
if (platform !== 'darwin') return null;
|
|
46
|
+
const log = path.join(home, 'Library', 'Logs', 'Claude', 'main.log');
|
|
47
|
+
try {
|
|
48
|
+
const size = fs.statSync(log).size;
|
|
49
|
+
const fd = fs.openSync(log, 'r');
|
|
50
|
+
const len = Math.min(size, 4 * 1024 * 1024);
|
|
51
|
+
const buf = Buffer.alloc(len);
|
|
52
|
+
fs.readSync(fd, buf, 0, len, size - len);
|
|
53
|
+
fs.closeSync(fd);
|
|
54
|
+
const all = [...buf.toString('utf8').matchAll(/scratch-workspaces\/([0-9a-f-]{36})\/([0-9a-f-]{36})/g)];
|
|
55
|
+
const last = all[all.length - 1];
|
|
56
|
+
return last ? { account: last[1], org: last[2] } : null;
|
|
57
|
+
} catch {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Every <root>/<account>/<org> folder, with how many sessions it holds and whether it is the signed-in one. */
|
|
63
|
+
export function listDesktopDirs({ home, platform }) {
|
|
64
|
+
const root = desktopRoot(home, platform);
|
|
65
|
+
if (!fs.existsSync(root)) throw new Error(`no Claude desktop Code-tab data at ${root}; open the Code tab once first`);
|
|
66
|
+
const active = activeAccountFromLog(home, platform);
|
|
67
|
+
return subdirs(root).flatMap((a) =>
|
|
68
|
+
subdirs(a).map((d) => {
|
|
69
|
+
const files = fs.readdirSync(d).filter((f) => /^local_.*\.json$/.test(f));
|
|
70
|
+
const newest = Math.max(0, ...files.map((f) => fs.statSync(path.join(d, f)).mtimeMs));
|
|
71
|
+
return { dir: d, account: path.basename(a), org: path.basename(d), entries: files.length, newest, active: !!active && active.account === path.basename(a) && active.org === path.basename(d) };
|
|
72
|
+
}),
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Which folder to write to: --desktop-dir if given, else the signed-in account/org (from the app log), else
|
|
78
|
+
* the only folder if there is just one. With several and no signal it refuses rather than guess, because a
|
|
79
|
+
* session written into another account's folder would never be seen.
|
|
80
|
+
*/
|
|
81
|
+
export function resolveDesktopDir({ home, dir, platform }) {
|
|
82
|
+
if (dir) {
|
|
83
|
+
if (!fs.existsSync(dir)) throw new Error(`desktop folder not found: ${dir}`);
|
|
84
|
+
return { dir: path.resolve(dir), how: 'given' };
|
|
85
|
+
}
|
|
86
|
+
const all = listDesktopDirs({ home, platform });
|
|
87
|
+
if (all.length === 0) throw new Error('no account folder in the desktop app data; open the Code tab once first');
|
|
88
|
+
const active = all.find((x) => x.active);
|
|
89
|
+
if (active) return { dir: active.dir, how: 'signed-in account (from the app log)' };
|
|
90
|
+
if (all.length === 1) return { dir: all[0].dir, how: 'only account folder' };
|
|
91
|
+
const rows = all.map((x) => ` ${x.dir} (${x.entries} session${x.entries === 1 ? '' : 's'})`).join('\n');
|
|
92
|
+
throw new Error(`cannot tell which desktop account is signed in; pick one with --desktop-dir:\n${rows}`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function readEntries(dir) {
|
|
96
|
+
const out = [];
|
|
97
|
+
for (const f of fs.readdirSync(dir)) {
|
|
98
|
+
if (!/^local_.*\.json$/.test(f)) continue;
|
|
99
|
+
try {
|
|
100
|
+
out.push({ file: f, json: JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8')) });
|
|
101
|
+
} catch {
|
|
102
|
+
// an unreadable entry is not ours to judge; skip it
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return out;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function lastModel(entries) {
|
|
109
|
+
let model = null;
|
|
110
|
+
for (const { rec } of entries) if (rec?.type === 'assistant' && typeof rec.message?.model === 'string') model = rec.message.model;
|
|
111
|
+
return model;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function firstPrompt(entries) {
|
|
115
|
+
for (const { rec } of entries) {
|
|
116
|
+
if (rec?.type !== 'user' || rec.isMeta || rec.isSidechain) continue;
|
|
117
|
+
const c = rec.message?.content;
|
|
118
|
+
const text = typeof c === 'string' ? c : Array.isArray(c) ? c.find((p) => p?.type === 'text')?.text : null;
|
|
119
|
+
if (text && !text.startsWith('<')) return text.replace(/\s+/g, ' ').trim().slice(0, 60);
|
|
120
|
+
}
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Build the index entry for an already-imported session. Pure; no I/O. */
|
|
125
|
+
export function buildDesktopEntry({ template, cliSessionId, root, transcript, now = Date.now(), uuid = crypto.randomUUID() }) {
|
|
126
|
+
const s = summarize(transcript);
|
|
127
|
+
const at = (iso, fallback) => (iso && Number.isFinite(Date.parse(iso)) ? Date.parse(iso) : fallback);
|
|
128
|
+
const entry = {
|
|
129
|
+
sessionId: `local_${uuid}`,
|
|
130
|
+
cliSessionId,
|
|
131
|
+
cwd: root,
|
|
132
|
+
originCwd: root,
|
|
133
|
+
createdAt: at(s.first, now),
|
|
134
|
+
lastActivityAt: at(s.last, now),
|
|
135
|
+
lastFocusedAt: at(s.last, now),
|
|
136
|
+
model: lastModel(transcript) || template.model,
|
|
137
|
+
isArchived: false,
|
|
138
|
+
title: s.title || firstPrompt(transcript) || `Imported session ${cliSessionId.slice(0, 8)}`,
|
|
139
|
+
titleSource: 'auto',
|
|
140
|
+
completedTurns: s.turns,
|
|
141
|
+
lastSpawnRootDetected: false,
|
|
142
|
+
alwaysAllowedReasons: [],
|
|
143
|
+
sessionPermissionUpdates: [],
|
|
144
|
+
spawnSeed: {},
|
|
145
|
+
};
|
|
146
|
+
if (template.generic) {
|
|
147
|
+
// Another account's session: take app settings only. Connectors and permission grants are account-specific,
|
|
148
|
+
// and the safest permission mode is the default one.
|
|
149
|
+
for (const k of GENERIC_KEYS) if (template[k] !== undefined) entry[k] = template[k];
|
|
150
|
+
entry.permissionMode = 'default';
|
|
151
|
+
entry.enabledMcpTools = {};
|
|
152
|
+
entry.remoteMcpServersConfig = [];
|
|
153
|
+
} else {
|
|
154
|
+
for (const k of KEEP_FROM_TEMPLATE) if (template[k] !== undefined) entry[k] = template[k];
|
|
155
|
+
}
|
|
156
|
+
return entry;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Register an imported session with the desktop app. Idempotent: a session
|
|
161
|
+
* that already has an index entry is left alone.
|
|
162
|
+
* Returns { status: 'registered' | 'exists' | 'would-register', file, dir }.
|
|
163
|
+
*/
|
|
164
|
+
export function registerDesktopSession({ id, root, mainFile, home, dir, platform, dryRun = false }) {
|
|
165
|
+
const { dir: target, how } = resolveDesktopDir({ home, dir, platform });
|
|
166
|
+
const existing = readEntries(target);
|
|
167
|
+
const have = existing.find(({ json }) => json.cliSessionId === id || (Array.isArray(json.priorCliSessionIds) && json.priorCliSessionIds.includes(id)));
|
|
168
|
+
if (have) return { status: 'exists', file: have.file, dir: target, how, entryCwd: have.json.cwd };
|
|
169
|
+
const newest = (list) => list.filter(({ json }) => !json.isArchived).sort((a, b) => (b.json.lastActivityAt || 0) - (a.json.lastActivityAt || 0))[0]?.json;
|
|
170
|
+
let template = newest(existing);
|
|
171
|
+
let borrowed = false;
|
|
172
|
+
if (!template) {
|
|
173
|
+
// A freshly signed-in account has no sessions yet; borrow app-level settings from any other account's.
|
|
174
|
+
const others = listDesktopDirs({ home, platform }).filter((x) => x.dir !== target).flatMap((x) => readEntries(x.dir));
|
|
175
|
+
const t = newest(others);
|
|
176
|
+
if (t) {
|
|
177
|
+
template = { ...t, generic: true };
|
|
178
|
+
borrowed = true;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
if (!template) throw new Error('no existing Code-tab session anywhere to copy app settings from; start one session in the desktop app Code tab first');
|
|
182
|
+
if (dryRun) return { status: 'would-register', file: 'a new local_*.json', dir: target, how, borrowed };
|
|
183
|
+
|
|
184
|
+
const entry = buildDesktopEntry({ template, cliSessionId: id, root, transcript: parseJsonl(fs.readFileSync(mainFile, 'utf8')) });
|
|
185
|
+
const file = `${entry.sessionId}.json`;
|
|
186
|
+
const dest = path.join(target, file);
|
|
187
|
+
const tmp = `${dest}.carry-tmp`;
|
|
188
|
+
fs.writeFileSync(tmp, JSON.stringify(entry), { mode: 0o600 });
|
|
189
|
+
fs.renameSync(tmp, dest);
|
|
190
|
+
return { status: 'registered', file, dir: target, how, borrowed };
|
|
191
|
+
}
|