agent-carry 0.2.1 → 0.4.1

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 CHANGED
@@ -1,16 +1,33 @@
1
1
  # Changelog
2
2
 
3
- ## Unreleased
3
+ Plugin, marketplace entry and package share one version number; `npm version` keeps them in step.
4
4
 
5
- - Plugin 0.2.1: commands check the CLI version first and say how to upgrade if it is older, instead of aborting on an unknown-command error.
6
- - 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.
5
+ ## 0.4.1
6
+ - `/carry:pickup` no longer asks which account to use or shows ids. `--desktop` picks the account the desktop app is signed in to (read from the app's own config, with the log as a fallback), and in a terminal asks only when the terminal and the desktop app are logged in as different accounts.
7
+ - Accounts are described by name and email where known (the terminal's login), otherwise by the sessions they hold, never by id: `carry desktop-dirs` and the import output now say which account's Code tab got the session.
8
+
9
+ ## 0.4.0
10
+
11
+ Finding sessions without knowing where they are stored. (0.3.0 was tagged but never published to npm.)
12
+
13
+ - `carry list --all` shows the most recent sessions across every folder, newest first, each tagged by where it was started (`[desktop]`, `[terminal]`, `[print mode]`) with the title the desktop app shows. Background-agent sessions (for example a memory plugin's observer) are hidden unless `--everything`.
14
+ - `carry list` options: `--limit N|all`, `--project DIR` (one folder), `--match TEXT` (title, folder or id), `--desktop` / `--terminal` (only sessions started there). Archived desktop sessions are marked; a desktop conversation with no project folder shows as "desktop scratch folder".
15
+ - `carry export` with no id offers a numbered picker that takes the same options.
16
+ - `/carry:share` uses the list; the plugin commands need CLI 0.4.0 or newer.
17
+
18
+ ## 0.2.1
19
+
20
+ - Plugin commands check the CLI version first and say how to upgrade if it is older, instead of aborting on an unknown-command error.
21
+
22
+ ## 0.2.0
23
+
24
+ - `carry import --desktop` adds the session to the Claude desktop app's Code tab (macOS). It targets the signed-in account (read from the app's log) and works for a freshly signed-in account by borrowing only app-level settings. Terminal colour codes in newly imported text are stripped, since the app shows them as literal characters.
25
+ - `export --latest` skips sessions with no prompts.
26
+ - Interactive plugin commands: `/carry:share` and `/carry:pickup` ask what they need (session, encryption, passphrase source, destination folder, desktop app and account). Installable with `/plugin install carry --marketplace harshhh28/agent-carry`.
7
27
  - `export --generate-passphrase` (saved to `FILE.pass`, never printed); `carry bundles`; `carry desktop-dirs`.
8
- - `--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.
9
28
 
10
- - Plugin installable from this repo: `/plugin install carry --marketplace harshhh28/agent-carry`.
11
- - `export --latest` skips sessions with no prompts, so an opened-and-closed session no longer shadows the real one.
12
- - `--desktop` strips terminal colour codes from newly imported text, which the app shows as literal characters.
13
- - `carry import --desktop` adds the session to the Claude desktop app's Code tab (macOS).
29
+ ## 0.1.1
30
+
14
31
  - `agent-carry` works as a second command name for `carry`.
15
32
  - README trimmed; plugin author set.
16
33
 
package/README.md CHANGED
@@ -16,9 +16,11 @@ copied session can't be resumed elsewhere. `carry` fixes that:
16
16
  ```
17
17
  npm i -g agent-carry # installs `carry` (also available as `agent-carry`)
18
18
 
19
- # machine A, in the project folder
20
- carry list
21
- carry export --latest -o session.carry.gz
19
+ # machine A (any folder): pick a session from a numbered list, with desktop-app titles
20
+ carry export -o session.carry.gz
21
+ # or: carry list --all then carry export <id> -o session.carry.gz
22
+ # more rows: --limit 50 | --limit all one folder: --project DIR search: --match word
23
+ # only desktop-app sessions: --desktop only terminal ones: --terminal (each row is tagged [desktop] / [terminal])
22
24
 
23
25
  # machine B, in the same project (any path)
24
26
  carry import session.carry.gz
@@ -40,7 +42,7 @@ carry import session.carry.gz --desktop
40
42
  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
43
  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.
42
44
 
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.
45
+ The session is added to the account the desktop app is signed in to. From a terminal that is logged in as a different account, `carry` stops and asks which Code tab you mean, naming the accounts (by name and email where known, otherwise by the sessions they hold). To choose explicitly use `--desktop-dir DIR`; `carry desktop-dirs` lists the accounts. 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.
44
46
 
45
47
  ### Sync through a shared folder
46
48
 
@@ -59,7 +61,7 @@ Projects are matched by git remote, not path. Each file has one writer, so there
59
61
  /plugin install carry --marketplace harshhh28/agent-carry
60
62
  ```
61
63
 
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.
64
+ Needs the CLI (`npm i -g agent-carry`, 0.4.0 or newer). Works the same in the terminal and in the desktop app's Code tab. To update later: `npm i -g agent-carry@latest`, then `/plugin marketplace update agent-carry` and `/reload-plugins`.
63
65
 
64
66
  - `/carry:share` asks which session, whether to encrypt (generate a passphrase, use your own file, or type one), and where to save.
65
67
  - `/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).
@@ -70,8 +72,8 @@ Passphrases are never put on a command line. A generated one is saved beside the
70
72
 
71
73
  | command | |
72
74
  |---|---|
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 list [--all] [--project DIR] [--desktop\|--terminal] [--match TEXT] [--limit N]` | sessions for this folder, or `--all`: recent sessions from every folder, each tagged by where it was started (`[desktop]`, `[terminal]`, `[print mode]`) with the desktop app's title. `--desktop` / `--terminal` keep only those. Background-agent sessions are hidden; `--everything` shows them |
76
+ | `carry export [id\|--latest] [-o FILE]` | with no id, pick from a numbered list (takes the same `--desktop`, `--terminal`, `--project`, `--match`). Also `--generate-passphrase`, `--no-redact`, `--redact-emails`, `--strip-thinking`, `--allow-thinking-secrets`, `--project-root DIR` |
75
77
  | `carry inspect FILE` | header, parts, redaction counts |
76
78
  | `carry import FILE [--project-root DIR] [--dry-run]` | write into the target folder's session store |
77
79
  | `carry bundles` / `carry desktop-dirs` | bundle files nearby; desktop-app account folders |
@@ -79,6 +81,7 @@ Passphrases are never put on a command line. A generated one is saved beside the
79
81
 
80
82
  ## Limits
81
83
 
84
+ - **Usage limit:** the CLI needs no Claude usage, so it still works when you have hit your limit; the plugin commands do not, because Claude has to run them.
82
85
  - **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
86
  - **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
87
  - **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.
@@ -89,7 +92,7 @@ Passphrases are never put on a command line. A generated one is saved beside the
89
92
  ## Development
90
93
 
91
94
  ```
92
- npm test # 49 tests, synthetic two-machine setups
95
+ npm test # 56 tests, synthetic two-machine setups
93
96
  npm run check:real # read-only round trip of your own ~/.claude sessions
94
97
  ```
95
98
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-carry",
3
- "version": "0.2.1",
3
+ "version": "0.4.1",
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": {
@@ -20,7 +20,9 @@
20
20
  },
21
21
  "scripts": {
22
22
  "test": "node --test \"test/*.test.js\"",
23
- "check:real": "node scripts/check-real.js"
23
+ "check:real": "node scripts/check-real.js",
24
+ "preversion": "npm test",
25
+ "version": "node scripts/sync-version.js && git add plugin/.claude-plugin/plugin.json .claude-plugin/marketplace.json CHANGELOG.md"
24
26
  },
25
27
  "keywords": [
26
28
  "claude-code",
@@ -1,7 +1,10 @@
1
1
  {
2
2
  "name": "carry",
3
- "version": "0.2.1",
3
+ "version": "0.4.1",
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": "Harsh Gajjar", "email": "harshgajjar2812@gmail.com" },
5
+ "author": {
6
+ "name": "Harsh Gajjar",
7
+ "email": "harshgajjar2812@gmail.com"
8
+ },
6
9
  "license": "MIT"
7
10
  }
@@ -4,7 +4,7 @@ argument-hint: [bundle-file]
4
4
  allowed-tools: Bash(carry:*), Bash(npx -y agent-carry@latest:*), Bash(ls:*), Bash(pwd), AskUserQuestion
5
5
  ---
6
6
 
7
- carry CLI version (this command needs 0.2.0 or newer): !`carry --version 2>&1 || echo 'not installed'`
7
+ carry CLI version (this command needs 0.4.0 or newer): !`carry --version 2>&1 || echo 'not installed'`
8
8
 
9
9
  Bundle files nearby (this folder, ~/Downloads, ~/Desktop), newest first:
10
10
 
@@ -12,13 +12,13 @@ Bundle files nearby (this folder, ~/Downloads, ~/Desktop), newest first:
12
12
 
13
13
  Current folder: !`pwd`
14
14
 
15
- Claude desktop app account folders (macOS only; an error here just means no desktop app):
15
+ Claude desktop app accounts (macOS only; an error here just means no desktop app):
16
16
 
17
17
  !`carry desktop-dirs 2>&1 || true`
18
18
 
19
19
  Arguments the user typed: $ARGUMENTS
20
20
 
21
- **Step 0.** Look at the version line above. If it says "not installed" or shows anything older than 0.2.0, stop and say exactly this, with nothing else: "This command needs the carry CLI 0.2.0 or newer. Run `npm i -g agent-carry@latest`, then try again." (If that install still gives an older version, 0.2.0 has not been published to npm yet.) Any error text shown in the listings below comes from the same cause; do not try to work around it.
21
+ **Step 0.** Look at the version line above. If it says "not installed" or shows anything older than 0.4.0, stop and say exactly this, with nothing else: "This command needs the carry CLI 0.4.0 or newer. Run `npm i -g agent-carry@latest`, then try again." (If that install still gives an older version, 0.4.0 has not been published to npm yet.) Any error text shown in the listings below comes from the same cause; do not try to work around it.
22
22
 
23
23
  You are importing one session with the `carry` CLI.
24
24
 
@@ -31,6 +31,6 @@ Ask with the AskUserQuestion tool, one short question at a time, and skip any qu
31
31
  - "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.
32
32
  (If `CARRY_PASSPHRASE` is already set in the environment, nothing is needed.) On a wrong-passphrase error, say so and ask once more.
33
33
  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`.
34
- 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.
34
+ 4. **Claude desktop app (only if account folders are listed above).** Ask only "Also show it in the Claude desktop app's Code tab?" Do not ask which account: `--desktop` picks the right one by itself (the account the desktop app is signed in to; in a terminal, the one that matches this terminal's login). Never show the user folder paths or ids. If the import fails with "this terminal is logged in as one Claude account but the desktop app is signed in as another", or "cannot tell which desktop account is signed in", ask once with AskUserQuestion, labelling each option with the account name and email, session count and example titles taken from that error (never ids or paths), and re-run with `--desktop-dir <that option's folder>`. A line starting with `*` in the listing above marks the account the desktop app is signed in to.
35
35
  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).
36
- 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.
36
+ 6. **Report in at most five lines:** the session id, the folder it went into, which account's Code tab it was added to (use the "Code tab of:" line from the import, e.g. a name and email; say it can be redone for another account if that is wrong), 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.
@@ -4,21 +4,23 @@ argument-hint: [optional carry export flags, e.g. -o FILE --strip-thinking]
4
4
  allowed-tools: Bash(carry:*), Bash(npx -y agent-carry@latest:*), AskUserQuestion
5
5
  ---
6
6
 
7
- carry CLI version (this command needs 0.2.0 or newer): !`carry --version 2>&1 || echo 'not installed'`
7
+ carry CLI version (this command needs 0.4.0 or newer): !`carry --version 2>&1 || echo 'not installed'`
8
8
 
9
- Sessions in this folder, newest first (the top one is usually this conversation):
9
+ Recent sessions from every folder, newest first, including ones started in the Claude desktop app (the top one is usually this conversation):
10
10
 
11
- !`carry list 2>&1 || npx -y agent-carry@latest list 2>&1 || true`
11
+ !`carry list --all --limit 8 2>&1 || true`
12
+
13
+ Current folder: !`pwd`
12
14
 
13
15
  Arguments the user typed: $ARGUMENTS
14
16
 
15
- **Step 0.** Look at the version line above. If it says "not installed" or shows anything older than 0.2.0, stop and say exactly this, with nothing else: "This command needs the carry CLI 0.2.0 or newer. Run `npm i -g agent-carry@latest`, then try again." (If that install still gives an older version, 0.2.0 has not been published to npm yet.) Any error text shown in the listings below comes from the same cause; do not try to work around it.
17
+ **Step 0.** Look at the version line above. If it says "not installed" or shows anything older than 0.4.0, stop and say exactly this, with nothing else: "This command needs the carry CLI 0.4.0 or newer. Run `npm i -g agent-carry@latest`, then try again." (If that install still gives an older version, 0.4.0 has not been published to npm yet.) Any error text shown in the listings below comes from the same cause; do not try to work around it.
16
18
 
17
19
  You are exporting one session with the `carry` CLI.
18
20
 
19
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 the same questions as a short numbered list in plain text and wait for the reply.
20
22
 
21
- 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.
23
+ 1. **Which session.** The user may not know where sessions are stored, so offer them from the list above, never ask for a folder. If the arguments name a session or `--latest`, use it. Otherwise ask, offering the newest first as "(Recommended)" and up to three more; label each with its title, and describe it with its folder, date, turn count and `[desktop app]` when shown. If none fit, offer "show more" (`carry list --all --limit 30`), or search by a word from the title or folder (`carry list --all --match WORD`), or one folder (`carry list --project DIR`). If the user only wants sessions from the Claude desktop app (or only terminal ones), list them with `carry list --all --desktop` (or `--terminal`); each row's tag in brackets shows where it was started. Export by id: `carry export <id>` works from any folder.
22
24
  2. **Encrypt?** Options:
23
25
  - "No, plain file": readable by anyone who gets it (secrets are still redacted).
24
26
  - "Yes, generate a passphrase (Recommended when sending it to someone)": run with `--generate-passphrase`.
package/src/browse.js ADDED
@@ -0,0 +1,69 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { projectFolders } from './store.js';
4
+ import { parseJsonl, summarize } from './transcript.js';
5
+ import { desktopSessionInfo, firstPrompt } from './desktop.js';
6
+
7
+ // The desktop app runs a conversation that has no project folder in a scratch folder with a long account/org path.
8
+ const SCRATCH = /\/scratch-workspaces\/[0-9a-f-]{36}\/[0-9a-f-]{36}(?:\/(.*))?$/;
9
+ const tilde = (p, home) => {
10
+ const scratch = p && SCRATCH.exec(p);
11
+ if (scratch) return `(desktop scratch folder${scratch[1] ? `: ${scratch[1]}` : ''})`;
12
+ return p && home && (p === home || p.startsWith(home + path.sep)) ? `~${p.slice(home.length)}` : p;
13
+ };
14
+
15
+ // Programmatic agents (for example a memory plugin's background observer) record this entrypoint. They are
16
+ // not conversations a person had, and on a machine with such a plugin they can outnumber real sessions 50 to 1.
17
+ /**
18
+ * Where a session was started, from the entrypoint Claude records on every line: `claude-desktop` is the desktop
19
+ * app, `cli` the terminal, `sdk-cli` a `claude -p` run. A session the desktop app lists in its index counts as
20
+ * desktop even if it began elsewhere (for example one imported with --desktop).
21
+ */
22
+ export function sourceOf(sum, inDesktopIndex) {
23
+ const e = sum.entrypoints;
24
+ if (inDesktopIndex || e.includes('claude-desktop')) return 'desktop';
25
+ if (e.includes('cli')) return 'terminal';
26
+ if (e.some((x) => x.startsWith('sdk'))) return 'print mode';
27
+ return e[0] ?? 'unknown';
28
+ }
29
+
30
+ const isBackgroundAgent = (sum) => sum.entrypoints.length > 0 && sum.entrypoints.every((e) => e === 'sdk-ts');
31
+
32
+ /**
33
+ * The most recent sessions across every project folder, so nobody needs to know where Claude keeps them.
34
+ * Skips sessions with no prompts and background-agent sessions (unless `everything`). Each gets the title the
35
+ * desktop app shows for it when there is one, else Claude's own title, else the first prompt.
36
+ * `source` ('desktop' or 'terminal') keeps only sessions started there. `folders` restricts the search to those project folders; `match` keeps rows whose title, folder or id contains the text.
37
+ */
38
+ export function recentSessions({ home, userHome, limit = 20, everything = false, folders, match, source }) {
39
+ const files = [];
40
+ for (const folder of folders ?? projectFolders(home)) {
41
+ if (!fs.existsSync(folder)) continue;
42
+ for (const n of fs.readdirSync(folder)) {
43
+ if (!n.endsWith('.jsonl')) continue;
44
+ const file = path.join(folder, n);
45
+ files.push({ file, folder, id: n.slice(0, -'.jsonl'.length), mtimeMs: fs.statSync(file).mtimeMs });
46
+ }
47
+ }
48
+ files.sort((a, b) => b.mtimeMs - a.mtimeMs);
49
+ const desk = desktopSessionInfo({ home: userHome });
50
+ const needle = match ? String(match).toLowerCase() : null;
51
+ const out = [];
52
+ for (const x of files) {
53
+ if (out.length >= limit) break;
54
+ const entries = parseJsonl(fs.readFileSync(x.file, 'utf8'));
55
+ const sum = summarize(entries);
56
+ if (sum.turns === 0 || (!everything && isBackgroundAgent(sum))) continue;
57
+ const d = desk.get(x.id);
58
+ const row = { ...x, ...sum, source: sourceOf(sum, !!d), archived: !!d?.archived, label: d?.title || sum.title || firstPrompt(entries) || '(untitled)', where: tilde(sum.dominantCwd ?? x.folder, userHome) };
59
+ if (source && row.source !== source) continue;
60
+ if (needle && !`${row.label} ${row.where} ${row.id}`.toLowerCase().includes(needle)) continue;
61
+ out.push(row);
62
+ }
63
+ return out;
64
+ }
65
+
66
+ export const formatSession = (s, n) => {
67
+ const when = new Date(s.mtimeMs).toISOString().slice(0, 16).replace('T', ' ');
68
+ return `${n === undefined ? '' : `${String(n).padStart(2)}. `}${s.id.slice(0, 8)} ${when} ${String(s.turns).padStart(3)} turns ${s.label} [${s.source}${s.archived ? ', archived' : ''}] ${s.where}`;
69
+ };
package/src/cli.js CHANGED
@@ -10,11 +10,14 @@ import crypto from 'node:crypto';
10
10
  import { isEncrypted } from './crypto.js';
11
11
  import { pushProject, pullProject } from './sync.js';
12
12
  import { registerDesktopSession, listDesktopDirs } from './desktop.js';
13
+ import { recentSessions, formatSession } from './browse.js';
13
14
 
14
15
  const HELP = `carry (agent-carry): move coding-agent sessions between machines and folders. Supported agent: claude-code
15
16
 
16
- carry list [--all] [--project DIR]
17
- carry export <id|prefix|--latest> [-o FILE] [--project-root DIR]
17
+ carry list [--all] [--project DIR] [--desktop | --terminal] [--match TEXT] [--limit N|all] [--everything]
18
+ (this folder, or --project DIR; --all: recent sessions from every folder; --desktop: only sessions
19
+ from the Claude desktop app; --terminal: only terminal ones; --match: title, folder or id contains TEXT)
20
+ carry export [id|prefix|--latest] [-o FILE] no id: pick from a numbered list (also --desktop, --terminal, --project DIR, --match TEXT, --limit N) [--project-root DIR]
18
21
  [--no-redact] [--redact-emails] [--strip-thinking | --allow-thinking-secrets]
19
22
  [--generate-passphrase] encrypt with a random passphrase saved next to the bundle as FILE.pass
20
23
  carry bundles bundle files in this folder, ~/Downloads and ~/Desktop
@@ -32,7 +35,7 @@ Global: --claude-home DIR (default $CLAUDE_CONFIG_DIR or ~/.claude), --home DIR
32
35
  function parseArgs(argv) {
33
36
  const flags = {};
34
37
  const pos = [];
35
- const valued = new Set(['-o', '--out', '--project', '--project-root', '--claude-home', '--home', '--name', '--passphrase-file', '--desktop-dir']);
38
+ const valued = new Set(['-o', '--out', '--project', '--project-root', '--claude-home', '--home', '--name', '--passphrase-file', '--desktop-dir', '--limit', '--match']);
36
39
  for (let i = 0; i < argv.length; i++) {
37
40
  const a = argv[i];
38
41
  if (a.startsWith('-') && a !== '-') {
@@ -43,6 +46,29 @@ function parseArgs(argv) {
43
46
  return { flags, pos };
44
47
  }
45
48
 
49
+ /** One line from stdin, or null at end of input. Synchronous so the rest of the CLI stays simple. */
50
+ function readLine() {
51
+ const buf = Buffer.alloc(1);
52
+ let line = '';
53
+ let got = false;
54
+ for (;;) {
55
+ let n;
56
+ try {
57
+ n = fs.readSync(0, buf, 0, 1, null);
58
+ } catch (e) {
59
+ if (e.code === 'EAGAIN') continue;
60
+ if (e.code === 'EOF') break;
61
+ throw e;
62
+ }
63
+ if (n === 0) break;
64
+ got = true;
65
+ const c = buf.toString();
66
+ if (c === '\n') return line.trim();
67
+ line += c;
68
+ }
69
+ return got ? line.trim() : null;
70
+ }
71
+
46
72
  function fail(msg) {
47
73
  process.stderr.write(`carry: ${msg}\n`);
48
74
  process.exit(1);
@@ -60,22 +86,25 @@ function main() {
60
86
  const cwd = flags['project-root'] || flags.project || process.cwd();
61
87
  let passphrase = flags['passphrase-file'] ? fs.readFileSync(flags['passphrase-file'], 'utf8').replace(/\r?\n$/, '') : process.env.CARRY_PASSPHRASE || undefined;
62
88
  const say = (m) => flags.quiet || process.stdout.write(`${m}\n`);
89
+ // --desktop / --terminal on list and export: only sessions started in the desktop app / in a terminal.
90
+ const sourceFlag = () => {
91
+ if (flags.desktop && flags.terminal) fail('use --desktop or --terminal, not both');
92
+ return flags.desktop ? 'desktop' : flags.terminal ? 'terminal' : undefined;
93
+ };
63
94
 
64
95
  try {
65
96
  if (!cmd || cmd === 'help' || flags.help) return process.stdout.write(HELP);
66
97
 
67
98
  if (cmd === 'list') {
68
- const folders = flags.all ? projectFolders(home) : [path.join(projectsDir(home), mangle(realpathOr(cwd)))];
69
- let n = 0;
70
- for (const f of folders) {
71
- for (const s of listSessions(f)) {
72
- n++;
73
- const when = new Date(s.mtimeMs).toISOString().slice(0, 16).replace('T', ' ');
74
- const where = flags.all ? ` ${s.dominantCwd ?? path.basename(f)}` : '';
75
- process.stdout.write(`${s.id.slice(0, 8)} ${when} ${String(s.turns).padStart(3)} turns ${s.title ?? '(untitled)'}${where}\n`);
76
- }
77
- }
78
- if (!n) process.stdout.write(`no sessions found for ${flags.all ? 'any project' : realpathOr(cwd)}\n`);
99
+ // Default: this folder (or --project DIR), every session. --all: recent sessions from every folder.
100
+ const scope = flags.all ? undefined : [path.join(projectsDir(home), mangle(realpathOr(cwd)))];
101
+ const limit = flags.limit === 'all' ? Infinity : Number(flags.limit) || (flags.all ? 20 : Infinity);
102
+ const list = recentSessions({ home, userHome, limit, everything: !!flags.everything, folders: scope, match: flags.match, source: sourceFlag() });
103
+ for (const x of list) process.stdout.write(`${formatSession(x)}\n`);
104
+ const where = flags.all ? 'any project' : realpathOr(cwd);
105
+ const what = sourceFlag() ? `${sourceFlag()} ` : '';
106
+ if (!list.length) process.stdout.write(`no ${what}sessions found for ${where}${flags.match ? ` matching "${flags.match}"` : ''}${flags.all ? '' : '; try: carry list --all'}\n`);
107
+ else if (list.length === limit) process.stdout.write(`(showing the ${limit} most recent; --limit N or --limit all for more)\n`);
79
108
  return;
80
109
  }
81
110
 
@@ -91,8 +120,19 @@ function main() {
91
120
  if (!s) fail(`every session for ${realpathOr(cwd)} is empty (no prompts yet); run \`carry list\``);
92
121
  if (s !== all[0]) process.stderr.write(`carry: skipped ${all.indexOf(s)} newer empty session(s); exporting ${s.id.slice(0, 8)}\n`);
93
122
  target = { folder: here, id: s.id };
123
+ } else if (!pos[0]) {
124
+ // No id and no --latest: pick from recent sessions anywhere, so nobody needs to know where they are stored.
125
+ const scope = flags.project ? [path.join(projectsDir(home), mangle(realpathOr(flags.project)))] : undefined;
126
+ const list = recentSessions({ home, userHome, limit: Number(flags.limit) || 10, folders: scope, match: flags.match, source: sourceFlag() });
127
+ if (!list.length) fail('no sessions found' + (flags.match ? ` matching "${flags.match}"` : '') + '; nothing to export');
128
+ list.forEach((x, i) => process.stdout.write(`${formatSession(x, i + 1)}\n`));
129
+ process.stdout.write('Export which one? [1] ');
130
+ const ans = readLine();
131
+ if (ans === null) fail('no session chosen; pass an id (see `carry list --all`) or --latest');
132
+ const pick = list[(ans === '' ? 1 : Number(ans)) - 1];
133
+ if (!pick) fail(`"${ans}" is not one of the numbers above`);
134
+ target = { folder: pick.folder, id: pick.id };
94
135
  } else {
95
- if (!pos[0]) fail('export needs a session id (see `carry list`) or --latest');
96
136
  // Same id can exist in several project folders (a session that was carried). Prefer the folder
97
137
  // named by --project, else the current one, and only then search everything.
98
138
  const scoped = path.join(projectsDir(home), mangle(realpathOr(flags.project || process.cwd())));
@@ -127,7 +167,7 @@ function main() {
127
167
  if (generated) fs.writeFileSync(`${out}.pass`, `${generated}\n`, { mode: 0o600 });
128
168
  const red = Object.entries(header.redaction.counts || {}).map(([k, v]) => `${k}:${v}`).join(' ') || 'none found';
129
169
  process.stdout.write(
130
- `exported ${header.sessionId.slice(0, 8)} "${header.title ?? 'untitled'}" -> ${out}\n` +
170
+ `exported ${header.sessionId.slice(0, 8)} "${header.title ?? 'untitled'}" -> ${path.resolve(out)}\n` +
131
171
  ` parts: ${header.parts.length} paths rewritten: ${header.pathsRewritten}\n` +
132
172
  ` redacted: ${header.redaction.enabled ? red : 'OFF'} encrypted: ${passphrase ? 'yes' : 'no'}\n` +
133
173
  (generated ? ` passphrase saved to ${out}.pass (not printed); share it by a different channel than the bundle\n` : ''),
@@ -179,7 +219,10 @@ function main() {
179
219
 
180
220
  if (cmd === 'desktop-dirs') {
181
221
  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`);
222
+ for (const x of list) {
223
+ const tags = [x.active ? 'signed in to the desktop app' : null, x.cli ? 'this terminal\'s login' : null].filter(Boolean).join(', ');
224
+ process.stdout.write(`${x.active ? '*' : ' '} ${x.label}${tags ? ` [${tags}]` : ''}\n --desktop-dir "${x.dir}"\n`);
225
+ }
183
226
  if (!list.length) process.stdout.write('no desktop account folders found\n');
184
227
  return;
185
228
  }
@@ -208,7 +251,7 @@ function main() {
208
251
  const d = registerDesktopSession({ id: plan.id, root: plan.root, mainFile: main.dest, home: userHome, dir: flags['desktop-dir'], dryRun: !!flags['dry-run'] });
209
252
  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
253
  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`);
254
+ if (d.label) process.stdout.write(` Code tab of: ${d.label}\n`);
212
255
  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
256
  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
257
  } catch (e) {
package/src/desktop.js CHANGED
@@ -59,37 +59,101 @@ export function activeAccountFromLog(home, platform = process.platform) {
59
59
  }
60
60
  }
61
61
 
62
- /** Every <root>/<account>/<org> folder, with how many sessions it holds and whether it is the signed-in one. */
62
+ /** The account the Claude Code CLI is logged in to, with its readable name, from ~/.claude.json. */
63
+ export function cliAccount(userHome) {
64
+ try {
65
+ const j = JSON.parse(fs.readFileSync(path.join(userHome, '.claude.json'), 'utf8'));
66
+ const a = j.oauthAccount;
67
+ if (!a?.accountUuid) return null;
68
+ return { account: a.accountUuid, org: a.organizationUuid ?? null, name: a.fullName || a.displayName || null, email: a.emailAddress ?? null, orgName: a.organizationName && !(a.emailAddress && a.organizationName.includes(a.emailAddress)) ? a.organizationName : null };
69
+ } catch {
70
+ return null;
71
+ }
72
+ }
73
+
74
+ /** The account the desktop app is signed in to: its own config.json records the last one. */
75
+ export function desktopSignedInAccount(home, platform = process.platform) {
76
+ if (platform !== 'darwin') return null;
77
+ try {
78
+ const j = JSON.parse(fs.readFileSync(path.join(home, 'Library', 'Application Support', 'Claude', 'config.json'), 'utf8'));
79
+ return typeof j.lastKnownAccountUuid === 'string' ? j.lastKnownAccountUuid : null;
80
+ } catch {
81
+ return null;
82
+ }
83
+ }
84
+
85
+ const plural = (n, w) => `${n} ${w}${n === 1 ? '' : 's'}`;
86
+
87
+ /**
88
+ * Every <root>/<account>/<org> folder, with a readable label. The app stores accounts by id only, so a name is
89
+ * known for the account this machine's Claude Code CLI is logged in to; any other account is described by how
90
+ * many sessions it holds and a title or two, which is what a person can recognise.
91
+ * `active`: the account the desktop app is signed in to. `cli`: the account the CLI uses.
92
+ */
63
93
  export function listDesktopDirs({ home, platform }) {
64
94
  const root = desktopRoot(home, platform);
65
95
  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) =>
96
+ const me = cliAccount(home);
97
+ const leaves = subdirs(root).flatMap((a) =>
68
98
  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) };
99
+ const entries = readEntries(d);
100
+ const files = entries.length;
101
+ const newest = Math.max(0, ...fs.readdirSync(d).filter((f) => /^local_.*\.json$/.test(f)).map((f) => fs.statSync(path.join(d, f)).mtimeMs));
102
+ const account = path.basename(a);
103
+ const org = path.basename(d);
104
+ const isMe = !!me && me.account === account;
105
+ const titles = entries.filter(({ json }) => json.title && !json.isArchived).sort((x, y) => (y.json.lastActivityAt || 0) - (x.json.lastActivityAt || 0)).map(({ json }) => String(json.title).slice(0, 40)).filter((t, i, all) => all.indexOf(t) === i).slice(0, 2).map((t) => `“${t}”`);
106
+ const who = isMe ? [me.name, me.email && `<${me.email}>`].filter(Boolean).join(' ') || 'This Claude Code login' : 'A different Claude account';
107
+ const orgName = isMe && me.org === org && me.orgName ? ` · ${me.orgName}` : '';
108
+ const label = `${who}${orgName} (${files === 0 ? 'no sessions in the Code tab yet' : `${plural(files, 'session')} in the Code tab`}${titles.length ? `, e.g. ${titles.join(', ')}` : ''})`;
109
+ return { dir: d, account, org, entries: files, newest, cli: isMe, name: isMe ? me.name : null, email: isMe ? me.email : null, label };
72
110
  }),
73
111
  );
112
+ // The signed-in desktop account (config.json; the app log as a fallback), and within it the org in use.
113
+ const log = activeAccountFromLog(home, platform);
114
+ const acct = desktopSignedInAccount(home, platform) ?? log?.account ?? null;
115
+ let activeDir = null;
116
+ if (acct) {
117
+ const mine = leaves.filter((x) => x.account === acct);
118
+ const pick = (log && log.account === acct && mine.find((x) => x.org === log.org)) || (me && me.account === acct && mine.find((x) => x.org === me.org)) || [...mine].sort((x, y) => y.newest - x.newest)[0];
119
+ activeDir = pick?.dir ?? null;
120
+ }
121
+ return leaves.map((x) => ({ ...x, active: x.dir === activeDir }));
74
122
  }
75
123
 
76
124
  /**
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.
125
+ * Which folder to write to, so nobody has to know account ids:
126
+ * - --desktop-dir if given;
127
+ * - in the desktop app, or when only one account could be meant: the account the desktop app is signed in to;
128
+ * - in a terminal: that same account if the terminal is logged in to it, but if the terminal and the desktop app
129
+ * are logged in as different accounts, ask (the error lists them by name), because a session written into the
130
+ * wrong account's Code tab would never be seen.
131
+ * `surface` is CLAUDE_CODE_ENTRYPOINT: 'cli' for a terminal, 'claude-desktop' for the desktop app.
80
132
  */
81
- export function resolveDesktopDir({ home, dir, platform }) {
133
+ export function resolveDesktopDir({ home, dir, platform, surface = process.env.CLAUDE_CODE_ENTRYPOINT }) {
82
134
  if (dir) {
83
135
  if (!fs.existsSync(dir)) throw new Error(`desktop folder not found: ${dir}`);
84
- return { dir: path.resolve(dir), how: 'given' };
136
+ return { dir: path.resolve(dir), how: 'chosen by you', label: null };
85
137
  }
86
138
  const all = listDesktopDirs({ home, platform });
87
139
  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}`);
140
+ const desk = all.find((x) => x.active);
141
+ const mine = all.filter((x) => x.cli);
142
+ const cli = mine.find((x) => x.active) ?? [...mine].sort((x, y) => y.newest - x.newest)[0];
143
+ if (surface === 'cli' && cli && desk && cli.account !== desk.account) {
144
+ const e = new Error(
145
+ `this terminal is logged in as one Claude account but the desktop app is signed in as another; which Code tab should get the session?\n` +
146
+ ` 1. ${cli.label} (--desktop-dir "${cli.dir}")\n 2. ${desk.label} (--desktop-dir "${desk.dir}")`,
147
+ );
148
+ e.choices = [cli, desk];
149
+ throw e;
150
+ }
151
+ const chosen = desk ?? (surface === 'cli' ? cli : null) ?? (all.length === 1 ? all[0] : null);
152
+ if (chosen) return { dir: chosen.dir, how: chosen === desk ? 'the signed-in desktop account' : 'the only account folder', label: chosen.label };
153
+ const rows = all.map((x, i) => ` ${i + 1}. ${x.label} (--desktop-dir "${x.dir}")`).join('\n');
154
+ const e = new Error(`cannot tell which desktop account is signed in; choose one:\n${rows}`);
155
+ e.choices = all;
156
+ throw e;
93
157
  }
94
158
 
95
159
  function readEntries(dir) {
@@ -111,7 +175,7 @@ function lastModel(entries) {
111
175
  return model;
112
176
  }
113
177
 
114
- function firstPrompt(entries) {
178
+ export function firstPrompt(entries) {
115
179
  for (const { rec } of entries) {
116
180
  if (rec?.type !== 'user' || rec.isMeta || rec.isSidechain) continue;
117
181
  const c = rec.message?.content;
@@ -162,10 +226,10 @@ export function buildDesktopEntry({ template, cliSessionId, root, transcript, no
162
226
  * Returns { status: 'registered' | 'exists' | 'would-register', file, dir }.
163
227
  */
164
228
  export function registerDesktopSession({ id, root, mainFile, home, dir, platform, dryRun = false }) {
165
- const { dir: target, how } = resolveDesktopDir({ home, dir, platform });
229
+ const { dir: target, how, label } = resolveDesktopDir({ home, dir, platform });
166
230
  const existing = readEntries(target);
167
231
  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 };
232
+ if (have) return { status: 'exists', file: have.file, dir: target, how, label, entryCwd: have.json.cwd };
169
233
  const newest = (list) => list.filter(({ json }) => !json.isArchived).sort((a, b) => (b.json.lastActivityAt || 0) - (a.json.lastActivityAt || 0))[0]?.json;
170
234
  let template = newest(existing);
171
235
  let borrowed = false;
@@ -179,7 +243,7 @@ export function registerDesktopSession({ id, root, mainFile, home, dir, platform
179
243
  }
180
244
  }
181
245
  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 };
246
+ if (dryRun) return { status: 'would-register', file: 'a new local_*.json', dir: target, how, label, borrowed };
183
247
 
184
248
  const entry = buildDesktopEntry({ template, cliSessionId: id, root, transcript: parseJsonl(fs.readFileSync(mainFile, 'utf8')) });
185
249
  const file = `${entry.sessionId}.json`;
@@ -187,5 +251,18 @@ export function registerDesktopSession({ id, root, mainFile, home, dir, platform
187
251
  const tmp = `${dest}.carry-tmp`;
188
252
  fs.writeFileSync(tmp, JSON.stringify(entry), { mode: 0o600 });
189
253
  fs.renameSync(tmp, dest);
190
- return { status: 'registered', file, dir: target, how, borrowed };
254
+ return { status: 'registered', file, dir: target, how, label, borrowed };
255
+ }
256
+
257
+ /** cliSessionId -> { title, archived } as the desktop app lists it. Empty when there is no desktop app data. */
258
+ export function desktopSessionInfo({ home, platform }) {
259
+ const out = new Map();
260
+ try {
261
+ for (const { dir } of listDesktopDirs({ home, platform })) {
262
+ for (const { json } of readEntries(dir)) if (json.cliSessionId) out.set(json.cliSessionId, { title: json.title || null, archived: !!json.isArchived });
263
+ }
264
+ } catch {
265
+ // no desktop app, unreadable data, or a platform we don't know: this is a nicety, not a requirement
266
+ }
267
+ return out;
191
268
  }