agent-carry 0.2.0 → 0.4.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 CHANGED
@@ -2,6 +2,9 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ - `carry list --all` now shows the most recent sessions across every folder, newest first, with the title the desktop app shows and the folder; background-agent sessions (e.g. a memory plugin's observer) are hidden unless `--everything`. `carry export` with no id offers a numbered picker. `--limit N|all` shows more, `--project DIR` lists one folder, `--match TEXT` filters by title, folder or id, and `--desktop` / `--terminal` keep only sessions started in the desktop app or a terminal (every row is tagged; archived desktop sessions are marked; a desktop session with no project folder shows as "desktop scratch folder"). Nobody needs to know where sessions are stored. `/carry:share` uses it (plugin 0.3.0).
6
+
7
+ - Plugin 0.2.1 (released): commands check the CLI version first and say how to upgrade if it is older, instead of aborting on an unknown-command error.
5
8
  - 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
9
  - `export --generate-passphrase` (saved to `FILE.pass`, never printed); `carry bundles`; `carry desktop-dirs`.
7
10
  - `--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.
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
@@ -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`). `--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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-carry",
3
- "version": "0.2.0",
3
+ "version": "0.4.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": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "carry",
3
- "version": "0.2.0",
3
+ "version": "0.3.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
5
  "author": { "name": "Harsh Gajjar", "email": "harshgajjar2812@gmail.com" },
6
6
  "license": "MIT"
@@ -4,19 +4,23 @@ 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'`
8
+
7
9
  Bundle files nearby (this folder, ~/Downloads, ~/Desktop), newest first:
8
10
 
9
- !`carry bundles 2>&1 || npx -y agent-carry@latest bundles 2>&1`
11
+ !`carry bundles 2>&1 || true`
10
12
 
11
13
  Current folder: !`pwd`
12
14
 
13
15
  Claude desktop app account folders (macOS only; an error here just means no desktop app):
14
16
 
15
- !`carry desktop-dirs 2>&1`
17
+ !`carry desktop-dirs 2>&1 || true`
16
18
 
17
19
  Arguments the user typed: $ARGUMENTS
18
20
 
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`.
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.
22
+
23
+ You are importing one session with the `carry` CLI.
20
24
 
21
25
  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
26
 
@@ -4,17 +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
- Sessions in this folder, newest first (the top one is usually this conversation):
7
+ carry CLI version (this command needs 0.2.0 or newer): !`carry --version 2>&1 || echo 'not installed'`
8
8
 
9
- !`carry list 2>&1 || npx -y agent-carry@latest list 2>&1`
9
+ Recent sessions from every folder, newest first, including ones started in the Claude desktop app (the top one is usually this conversation):
10
+
11
+ !`carry list --all --limit 8 2>&1 || true`
12
+
13
+ Current folder: !`pwd`
10
14
 
11
15
  Arguments the user typed: $ARGUMENTS
12
16
 
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`.
17
+ **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.
18
+
19
+ You are exporting one session with the `carry` CLI.
14
20
 
15
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.
16
22
 
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.
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.
18
24
  2. **Encrypt?** Options:
19
25
  - "No, plain file": readable by anyone who gets it (secrets are still redacted).
20
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` : ''),
package/src/desktop.js CHANGED
@@ -111,7 +111,7 @@ function lastModel(entries) {
111
111
  return model;
112
112
  }
113
113
 
114
- function firstPrompt(entries) {
114
+ export function firstPrompt(entries) {
115
115
  for (const { rec } of entries) {
116
116
  if (rec?.type !== 'user' || rec.isMeta || rec.isSidechain) continue;
117
117
  const c = rec.message?.content;
@@ -189,3 +189,16 @@ export function registerDesktopSession({ id, root, mainFile, home, dir, platform
189
189
  fs.renameSync(tmp, dest);
190
190
  return { status: 'registered', file, dir: target, how, borrowed };
191
191
  }
192
+
193
+ /** cliSessionId -> { title, archived } as the desktop app lists it. Empty when there is no desktop app data. */
194
+ export function desktopSessionInfo({ home, platform }) {
195
+ const out = new Map();
196
+ try {
197
+ for (const { dir } of listDesktopDirs({ home, platform })) {
198
+ for (const { json } of readEntries(dir)) if (json.cliSessionId) out.set(json.cliSessionId, { title: json.title || null, archived: !!json.isArchived });
199
+ }
200
+ } catch {
201
+ // no desktop app, unreadable data, or a platform we don't know: this is a nicety, not a requirement
202
+ }
203
+ return out;
204
+ }