@100xprompt/brain 0.1.4 → 0.1.6
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/README.md +41 -1
- package/package.json +1 -1
- package/src/adapters/amazon-q-ide/writer.ts +3 -1
- package/src/adapters/antigravity/reader.ts +25 -7
- package/src/adapters/claude-code/reader.ts +4 -0
- package/src/adapters/claude-desktop/index.ts +8 -0
- package/src/adapters/claude-desktop/reader.ts +140 -0
- package/src/adapters/claude-desktop/records.ts +191 -0
- package/src/adapters/claude-desktop/writer.ts +112 -0
- package/src/adapters/codex/reader.ts +19 -5
- package/src/adapters/codex/records.ts +17 -0
- package/src/adapters/codex/writer.ts +20 -7
- package/src/adapters/codex-desktop/index.ts +38 -0
- package/src/adapters/index.ts +4 -0
- package/src/cli.ts +79 -71
- package/src/dashboard/cli.ts +17 -2
- package/src/dashboard/report.ts +98 -5
- package/src/dashboard/sessions.ts +127 -0
- package/src/dashboard/state.ts +64 -5
- package/src/gateway/common.ts +8 -0
- package/src/gateway/connect.ts +33 -3
- package/src/gateway/connectors/antigravity.ts +205 -5
- package/src/gateway/connectors/claude-desktop.ts +169 -0
- package/src/gateway/connectors/index.ts +2 -0
- package/src/handoff.ts +18 -2
- package/src/hooks.ts +2 -2
- package/src/lock.ts +3 -0
- package/src/memory/mcp-install.ts +9 -0
- package/src/memory/sync.ts +25 -3
- package/src/moves.ts +402 -0
- package/src/service.ts +490 -0
- package/src/session/open.ts +82 -0
- package/src/session/ops.ts +111 -0
- package/src/settings/account.ts +65 -10
- package/src/settings/catalog.ts +20 -2
- package/src/settings/cli.ts +18 -3
- package/src/settings/env.ts +2 -0
- package/src/settings/pair.ts +3 -0
- package/src/settings/values.ts +28 -0
package/README.md
CHANGED
|
@@ -116,6 +116,8 @@ brain gateway logout
|
|
|
116
116
|
| OpenCode (`opencode`) | reads brain's key file | yes | yes |
|
|
117
117
|
| Gemini CLI (`gemini-cli`) | copy in `~/.gemini/.env`, private | no | yes (needs the gateway update that accepts `x-goog-api-key`) |
|
|
118
118
|
| Aider (`aider`) | copy in its model settings, private | yes | yes |
|
|
119
|
+
| Claude Desktop (`claude-desktop`, macOS) | its third-party mode's credential helper (`~/.brain/bin/claude-desktop-key`) reads brain's key file each time | yes | the app's own Claude Code (2.1.317) against the gateway's code; the app itself once you quit and reopen it |
|
|
120
|
+
| Antigravity (`antigravity`, macOS) | its launcher, "Antigravity with 100xprompt" in `~/Applications`, reads brain's key file each time it starts Antigravity | yes | its agent (`agy` 1.5.5) against a local stand-in; the IDE through the launcher not yet (needs the gateway update with the model lists) |
|
|
119
121
|
| Cline CLI (`cline-sdk`) | copy in its providers file, private | yes | yes |
|
|
120
122
|
| Continue (`continue`) | copy in `~/.continue/.env`, private | yes | yes (`cn`) |
|
|
121
123
|
| Copilot CLI (`copilot-cli`) | copy in its providers file, private | yes | yes |
|
|
@@ -141,12 +143,24 @@ brain gateway logout
|
|
|
141
143
|
route with the key and the gateway's model, and `disconnect` gave every file back byte for byte (its original mode too).
|
|
142
144
|
|
|
143
145
|
Some tools cannot be pointed at the gateway from a file brain can safely write and cleanly undo, so they have no connector:
|
|
144
|
-
Cursor and the Cursor CLI, Windsurf,
|
|
146
|
+
Cursor and the Cursor CLI, Windsurf, Amazon Q, Kiro, Auggie, Tabnine, Devin and Muse Code (their vendor's own
|
|
145
147
|
servers only); Zed (the key only in the keychain or its launch environment); Void (settings encrypted in the app's state);
|
|
146
148
|
Roo Code and the Cline extension (they keep the key in the editor's own storage, so disconnect could not take it back);
|
|
147
149
|
Codebuff and Qoder (custom endpoints only through their own sign-in or the keychain). The reason and its source are in each
|
|
148
150
|
tool's file under `src/gateway/connectors/`.
|
|
149
151
|
|
|
152
|
+
Claude Desktop connects through its own third-party mode, "Claude (third-party)": brain adds its entry to the app's
|
|
153
|
+
third-party configurations and applies it, with a credential helper that prints the key from brain's key file. The app
|
|
154
|
+
names gateway models as Anthropic ones, so the menu's 100xprompt Pro and Flash (or Auto) are sent as `anthropic/100x-pro`
|
|
155
|
+
and `anthropic/100x-flash`. Third-party mode is a separate profile with its own sessions: quit Claude and open it again to
|
|
156
|
+
start in it; sign in with your Claude account in the app to go back.
|
|
157
|
+
|
|
158
|
+
Antigravity's agent takes the gateway only from its launch environment, so brain does not change Antigravity itself: it
|
|
159
|
+
writes a launcher app that starts Antigravity with that environment (the key read from brain's key file at that moment,
|
|
160
|
+
never written into the launcher). Open Antigravity from "Antigravity with 100xprompt" to get 100xprompt's models; opened
|
|
161
|
+
the usual way it keeps Google's. If Antigravity is already open, quit it first: the launcher can only give the gateway to
|
|
162
|
+
an Antigravity it starts, and says so when it finds another one running.
|
|
163
|
+
|
|
150
164
|
Gemini CLI loads only the first `.env` it finds from the project folder up: in a project with its own `.env`, put the
|
|
151
165
|
gateway lines there or export them. Letta Code uses the gateway for its local agents only (cloud agents call models from
|
|
152
166
|
Letta's servers). Tools that read brain's key file stop working after `logout` until the next `login`; Kilo refuses to start
|
|
@@ -210,6 +224,31 @@ brain pair # approve this computer in your 100xprompt account (in the
|
|
|
210
224
|
brain pair status # paired or not, and the last exchange with the account
|
|
211
225
|
```
|
|
212
226
|
|
|
227
|
+
To have changes you make in the dashboard apply here by themselves, run brain in the background:
|
|
228
|
+
|
|
229
|
+
```bash
|
|
230
|
+
brain service install # macOS (launchd) or Linux (systemd --user); every minute it takes and applies dashboard changes
|
|
231
|
+
brain service status # installed, running, and its last round
|
|
232
|
+
brain service uninstall
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
It uses the folders and opt-outs your shell had when you last ran a brain command (`~/.brain/env.json`), restarts itself
|
|
236
|
+
when you update brain, and waits longer between tries while you are offline. Its log is `~/.brain/logs/service.log`. Run
|
|
237
|
+
`brain service uninstall` before removing brain (if you forget, it stops by itself). Without it, `brain status` or
|
|
238
|
+
`brain sync` take dashboard changes by hand.
|
|
239
|
+
|
|
240
|
+
A paired computer also sends its session list (each session's tool, title, project folder name and when; never chat
|
|
241
|
+
text) so the dashboard lists every session, shows the command that opens it (`brain session open <id>`), and can move one
|
|
242
|
+
to another tool with a button. A move from the dashboard runs here through the background service, and by default this
|
|
243
|
+
computer asks you first:
|
|
244
|
+
|
|
245
|
+
```bash
|
|
246
|
+
brain settings set moves.fromDashboard ask # ask first (default); allow: move without asking; off: refuse them
|
|
247
|
+
brain settings set reports.sessions off # stop sending the session list
|
|
248
|
+
brain moves # moves that wait for you here (when there was no way to ask)
|
|
249
|
+
brain session open <id> # open a session again in its own tool, in its own folder
|
|
250
|
+
```
|
|
251
|
+
|
|
213
252
|
Pairing gives this computer its own token (`~/.brain/machine.key`, readable only by you), separate from the gateway key
|
|
214
253
|
your tools use: model keys never read or change settings. A paired computer sends the changes made here to your account
|
|
215
254
|
and takes the ones made there when you run `brain status`, `brain settings` or `brain sync` (a few seconds at most; the
|
|
@@ -266,6 +305,7 @@ brain status
|
|
|
266
305
|
brain settings [list | get | set | unset | history | ids]
|
|
267
306
|
brain sync
|
|
268
307
|
brain pair [status | forget]
|
|
308
|
+
brain service install | uninstall | status
|
|
269
309
|
brain org login | logout | whoami | status | promote | demote | forget | admin ...
|
|
270
310
|
brain expand <id>
|
|
271
311
|
brain --version
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@100xprompt/brain",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.6",
|
|
4
4
|
"description": "One working session, any AI coding tool: move sessions between Claude Code, Codex, Cursor, Gemini CLI, OpenCode and more, keep what projects learned, spend fewer tokens. Runs on Bun. By 100xprompt.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Nipurn Agarwal",
|
|
@@ -314,7 +314,9 @@ export function amazonQWriter(opts: { server?: string; running?: () => boolean }
|
|
|
314
314
|
way: "native",
|
|
315
315
|
sessionId: historyId,
|
|
316
316
|
written: had ? [file, join(backupDir, basename(file))] : [file],
|
|
317
|
-
|
|
317
|
+
// The title goes in the note, never the command: zsh at a prompt does not take # as a comment.
|
|
318
|
+
open: `${editor} ${shellQuote(cwd)}`,
|
|
319
|
+
note: `Amazon Q reopens "${plan.tab.title as string}" when the folder opens; it is also in Amazon Q's History`,
|
|
318
320
|
lost: plan.lost,
|
|
319
321
|
dryRun: !!w.dryRun,
|
|
320
322
|
}
|
|
@@ -57,7 +57,15 @@ function pathOfUri(uri: unknown): string | undefined {
|
|
|
57
57
|
}
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
-
const WANTED = ["conversation_id", "title", "workspace_uris", "last_modified_time", "parent_conversation_id"]
|
|
60
|
+
const WANTED = ["conversation_id", "title", "preview", "workspace_uris", "last_modified_time", "parent_conversation_id"]
|
|
61
|
+
|
|
62
|
+
/** The IDE leaves `title` empty and keeps the person's first words in `preview`: its first line is the title then. */
|
|
63
|
+
function titleOf(r: Record<string, unknown>): string | undefined {
|
|
64
|
+
if (typeof r.title === "string" && r.title.trim()) return r.title
|
|
65
|
+
if (typeof r.preview !== "string") return undefined
|
|
66
|
+
const line = r.preview.split(/\r?\n/).map((l) => l.replace(/\s+/g, " ").trim()).find(Boolean)
|
|
67
|
+
return line ? (line.length <= 80 ? line : `${line.slice(0, 79)}…`) : undefined
|
|
68
|
+
}
|
|
61
69
|
|
|
62
70
|
/** Summary rows by conversation id, from whichever of the wanted columns the table has. */
|
|
63
71
|
function summaryRows(d: Database, id?: string): Map<string, Summary> {
|
|
@@ -76,8 +84,9 @@ function summaryRows(d: Database, id?: string): Map<string, Summary> {
|
|
|
76
84
|
} catch {
|
|
77
85
|
// no folder
|
|
78
86
|
}
|
|
87
|
+
const title = titleOf(r)
|
|
79
88
|
out.set(String(r.conversation_id), {
|
|
80
|
-
...(
|
|
89
|
+
...(title ? { title } : {}),
|
|
81
90
|
...(cwd ? { cwd } : {}),
|
|
82
91
|
...(sqlTime(r.last_modified_time) ? { updatedAt: sqlTime(r.last_modified_time)! } : {}),
|
|
83
92
|
...(typeof r.parent_conversation_id === "string" && r.parent_conversation_id ? { parent: r.parent_conversation_id } : {}),
|
|
@@ -101,12 +110,19 @@ export const antigravityReader: Reader = {
|
|
|
101
110
|
label: LABEL,
|
|
102
111
|
async list(home) {
|
|
103
112
|
const found = new Map<string, SessionRef>()
|
|
113
|
+
// Summaries from every store: the IDE keeps its live conversations in one store and their summaries (titles, folders)
|
|
114
|
+
// in another, so a conversation takes the summary of its id wherever it is (one with a title first).
|
|
115
|
+
const sums = new Map<string, Summary>()
|
|
116
|
+
for (const store of STORES)
|
|
117
|
+
for (const [id, s] of readSummaries(summariesPath(home, store))) {
|
|
118
|
+
const had = sums.get(id)
|
|
119
|
+
if (!had || (!had.title && s.title) || (!had.cwd && s.cwd && !!had.title === !!s.title)) sums.set(id, { ...s, ...(had?.title && !s.title ? { title: had.title } : {}), ...(had?.cwd && !s.cwd ? { cwd: had.cwd } : {}) })
|
|
120
|
+
}
|
|
104
121
|
for (const store of STORES) {
|
|
105
122
|
const dir = conversationsDir(home, store)
|
|
106
123
|
// Only the SQLite conversations: the IDE's <uuid>.pb files are encrypted.
|
|
107
124
|
const files = ls(dir).filter((n) => CONVERSATION_FILE.test(n))
|
|
108
125
|
if (!files.length) continue
|
|
109
|
-
const sums = readSummaries(summariesPath(home, store))
|
|
110
126
|
for (const name of files) {
|
|
111
127
|
const id = name.slice(0, -3)
|
|
112
128
|
const path = join(dir, name)
|
|
@@ -121,18 +137,20 @@ export const antigravityReader: Reader = {
|
|
|
121
137
|
...(updatedAt ? { updatedAt } : {}),
|
|
122
138
|
extra: { store },
|
|
123
139
|
}
|
|
124
|
-
// The same conversation in two stores: the
|
|
140
|
+
// The same conversation in two stores: the copy written last (its file's time; both share one summary).
|
|
125
141
|
const prev = found.get(id)
|
|
126
|
-
|
|
142
|
+
const fileAt = mtime(path) ?? ""
|
|
143
|
+
if (!prev || fileAt > String(prev.extra?.fileAt ?? "")) found.set(id, { ...ref, extra: { ...ref.extra, fileAt } })
|
|
127
144
|
}
|
|
128
145
|
}
|
|
129
146
|
return [...found.values()].sort((a, b) => (b.updatedAt ?? "").localeCompare(a.updatedAt ?? ""))
|
|
130
147
|
},
|
|
131
|
-
async read(ref) {
|
|
148
|
+
async read(ref, home) {
|
|
132
149
|
if (!exists(ref.path)) throw new Error(`Antigravity conversation not found: ${ref.path}`)
|
|
133
150
|
if (statSync(ref.path).size > MAX_DB_BYTES) throw new Error(`Antigravity conversation is larger than ${MAX_DB_BYTES} bytes: ${ref.path}`)
|
|
134
151
|
const id = basename(ref.path).replace(/\.db$/i, "")
|
|
135
|
-
|
|
152
|
+
// Its own store's summary, else another store's (the IDE keeps them apart).
|
|
153
|
+
const s = readSummaries(join(dirname(dirname(ref.path)), "conversation_summaries.db"), id).get(id) ?? STORES.map((st) => readSummaries(summariesPath(home, st), id).get(id)).find(Boolean)
|
|
136
154
|
const title = s?.title ?? ref.title
|
|
137
155
|
const cwd = s?.cwd ?? ref.cwd
|
|
138
156
|
const updatedAt = s?.updatedAt ?? ref.updatedAt
|
|
@@ -7,6 +7,7 @@ import { peekCache } from "../../session/peekcache.ts"
|
|
|
7
7
|
import { clip, header, iso, lossCounter, noteModel, toolKindOf } from "../../session/util.ts"
|
|
8
8
|
import type { Reader, SessionRef } from "../types.ts"
|
|
9
9
|
import { CHAIN_TYPES, CONTINUED_NOTE, FILE_ATTACHMENTS, HOUSEKEEPING, LABEL, SESSION_FILE, TOOL, projectSlug, projectsDir } from "./records.ts"
|
|
10
|
+
import { desktopTranscriptIds } from "../claude-desktop/records.ts"
|
|
10
11
|
|
|
11
12
|
// Reads Claude Code transcripts: <home>/.claude/projects/<project>/<session>.jsonl, with subagents in
|
|
12
13
|
// <session>/subagents/ and spilled tool outputs in <session>/tool-results/.
|
|
@@ -592,10 +593,13 @@ export const claudeCodeReader: Reader = {
|
|
|
592
593
|
const refs: SessionRef[] = []
|
|
593
594
|
// Peeking opens every transcript; the result is reused while a file's size and time (and its title file's) are unchanged.
|
|
594
595
|
const cache = peekCache(home, "claude-peek", 1)
|
|
596
|
+
// Sessions Claude Desktop keeps a record of are its sessions (listed as claude-desktop, with the app's titles).
|
|
597
|
+
const desktop = desktopTranscriptIds(home)
|
|
595
598
|
for (const project of ls(root)) {
|
|
596
599
|
const dir = join(root, project)
|
|
597
600
|
for (const name of ls(dir)) {
|
|
598
601
|
if (!SESSION_FILE.test(name)) continue
|
|
602
|
+
if (desktop.has(name.slice(0, -".jsonl".length))) continue
|
|
599
603
|
const path = join(dir, name)
|
|
600
604
|
try {
|
|
601
605
|
const st = statSync(path)
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { Reader, Writer } from "../types.ts"
|
|
2
|
+
import { claudeDesktopReader } from "./reader.ts"
|
|
3
|
+
import { claudeDesktopNativeWriter } from "./writer.ts"
|
|
4
|
+
|
|
5
|
+
// Claude Desktop (the Claude app's Code tab): its sessions are Claude Code transcripts the app keeps a record of (title,
|
|
6
|
+
// folder, account). Read from those records; continued into the app as a transcript plus a record (records.ts).
|
|
7
|
+
export const readers: Reader[] = [claudeDesktopReader]
|
|
8
|
+
export const writers: Writer[] = [claudeDesktopNativeWriter]
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// Claude Desktop's Code sessions: listed from the app's records (their titles, folders, last activity), read from the
|
|
2
|
+
// Claude Code transcripts each record points at (records.ts). A record whose transcript is gone is not offered.
|
|
3
|
+
//
|
|
4
|
+
// A session the app resumed or rewound has several transcripts: the current one (cliSessionId) and the earlier ones
|
|
5
|
+
// (priorCliSessionIds), each holding part of the history. Reading the session reads all of them, oldest first, so a move
|
|
6
|
+
// carries the whole history; an earlier part that is gone is said in `lost`.
|
|
7
|
+
import { existsSync, readdirSync, statSync } from "node:fs"
|
|
8
|
+
import { join } from "node:path"
|
|
9
|
+
import { claudeCodeReader } from "../claude-code/reader.ts"
|
|
10
|
+
import { projectSlug, projectsDir } from "../claude-code/records.ts"
|
|
11
|
+
import type { Reader, SessionRef } from "../types.ts"
|
|
12
|
+
import type { Session, Turn } from "../../session/types.ts"
|
|
13
|
+
import { LABEL, readRecords, TOOL } from "./records.ts"
|
|
14
|
+
|
|
15
|
+
const iso = (ms?: number) => (typeof ms === "number" && Number.isFinite(ms) && ms > 0 ? new Date(ms).toISOString() : undefined)
|
|
16
|
+
const mtime = (p: string) => {
|
|
17
|
+
try {
|
|
18
|
+
return statSync(p).mtimeMs
|
|
19
|
+
} catch {
|
|
20
|
+
return undefined
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Where transcript `<id>.jsonl` is: in its folder's slug first, else anywhere under ~/.claude/projects. */
|
|
25
|
+
function transcriptFinder(home: string): (id: string, cwd?: string) => string | undefined {
|
|
26
|
+
let all: Map<string, string> | undefined
|
|
27
|
+
return (id, cwd) => {
|
|
28
|
+
if (cwd) {
|
|
29
|
+
const p = join(projectsDir(home), projectSlug(cwd), `${id}.jsonl`)
|
|
30
|
+
if (existsSync(p)) return p
|
|
31
|
+
}
|
|
32
|
+
if (!all) {
|
|
33
|
+
all = new Map()
|
|
34
|
+
try {
|
|
35
|
+
for (const dir of readdirSync(projectsDir(home)))
|
|
36
|
+
try {
|
|
37
|
+
for (const f of readdirSync(join(projectsDir(home), dir))) if (f.endsWith(".jsonl") && !all.has(f)) all.set(f, join(projectsDir(home), dir, f))
|
|
38
|
+
} catch {}
|
|
39
|
+
} catch {}
|
|
40
|
+
}
|
|
41
|
+
return all.get(`${id}.jsonl`)
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The same turn read twice (a transcript that starts with a copy of the one before): same kind, time and content. */
|
|
46
|
+
const turnKey = (t: Turn) => (t.at ? `${t.kind}|${t.at}|${JSON.stringify(t).slice(0, 400)}` : undefined)
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The turns of a session's transcripts, oldest transcript first: each turn once, in time order (a turn without a time
|
|
50
|
+
* stays right after the turn before it in its own transcript).
|
|
51
|
+
*/
|
|
52
|
+
export function mergeTurns(parts: Session[]): Turn[] {
|
|
53
|
+
const seen = new Set<string>()
|
|
54
|
+
const out: { t: Turn; i: number; at: string }[] = []
|
|
55
|
+
let i = 0
|
|
56
|
+
for (const s of parts) {
|
|
57
|
+
let last = ""
|
|
58
|
+
for (const t of s.turns) {
|
|
59
|
+
if (t.at) last = t.at
|
|
60
|
+
const k = turnKey(t)
|
|
61
|
+
if (k) {
|
|
62
|
+
if (seen.has(k)) continue
|
|
63
|
+
seen.add(k)
|
|
64
|
+
}
|
|
65
|
+
out.push({ t, i: i++, at: last })
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return out.sort((a, b) => a.at.localeCompare(b.at) || a.i - b.i).map((x) => x.t)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const firstAt = (s: Session) => s.turns.find((t) => t.at)?.at ?? s.header.startedAt ?? ""
|
|
72
|
+
|
|
73
|
+
export const claudeDesktopReader: Reader = {
|
|
74
|
+
tool: TOOL,
|
|
75
|
+
label: LABEL,
|
|
76
|
+
|
|
77
|
+
async list(home: string): Promise<SessionRef[]> {
|
|
78
|
+
const find = transcriptFinder(home)
|
|
79
|
+
const refs: SessionRef[] = []
|
|
80
|
+
for (const r of readRecords(home)) {
|
|
81
|
+
if (!r.cliSessionId) continue
|
|
82
|
+
const path = find(r.cliSessionId, r.cwd)
|
|
83
|
+
if (!path) continue
|
|
84
|
+
// `claude --resume <id>` in a terminal adds to the same transcript without touching the app's record: the session's
|
|
85
|
+
// last activity is whichever is later.
|
|
86
|
+
const at = Math.max(r.lastActivityAt ?? 0, mtime(path) ?? 0)
|
|
87
|
+
refs.push({
|
|
88
|
+
tool: TOOL,
|
|
89
|
+
sourceId: r.sessionId,
|
|
90
|
+
path,
|
|
91
|
+
...(r.title ? { title: r.title } : {}),
|
|
92
|
+
...(r.cwd ? { cwd: r.cwd } : {}),
|
|
93
|
+
...(iso(at) ? { updatedAt: iso(at) } : {}),
|
|
94
|
+
extra: { cliSessionId: r.cliSessionId, ...(r.priorCliSessionIds.length ? { priorCliSessionIds: r.priorCliSessionIds } : {}), archived: r.isArchived },
|
|
95
|
+
})
|
|
96
|
+
}
|
|
97
|
+
return refs
|
|
98
|
+
},
|
|
99
|
+
|
|
100
|
+
async read(ref: SessionRef, home: string): Promise<Session> {
|
|
101
|
+
const cli = typeof ref.extra?.cliSessionId === "string" ? ref.extra.cliSessionId : undefined
|
|
102
|
+
const s = await claudeCodeReader.read({ tool: "claude-code", sourceId: cli ?? ref.sourceId, path: ref.path }, home)
|
|
103
|
+
const priors = Array.isArray(ref.extra?.priorCliSessionIds) ? (ref.extra.priorCliSessionIds as unknown[]).filter((x): x is string => typeof x === "string" && x !== cli) : []
|
|
104
|
+
if (priors.length) {
|
|
105
|
+
const find = transcriptFinder(home)
|
|
106
|
+
const parts: Session[] = []
|
|
107
|
+
const gone: string[] = []
|
|
108
|
+
for (const id of [...new Set(priors)]) {
|
|
109
|
+
const path = find(id, s.header.project.cwd ?? ref.cwd)
|
|
110
|
+
if (!path) {
|
|
111
|
+
gone.push(id)
|
|
112
|
+
continue
|
|
113
|
+
}
|
|
114
|
+
try {
|
|
115
|
+
parts.push(await claudeCodeReader.read({ tool: "claude-code", sourceId: id, path }, home))
|
|
116
|
+
} catch {
|
|
117
|
+
gone.push(id)
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
parts.sort((a, b) => firstAt(a).localeCompare(firstAt(b)))
|
|
121
|
+
if (parts.length) {
|
|
122
|
+
s.turns = mergeTurns([...parts, s])
|
|
123
|
+
s.children = [...parts.flatMap((p) => p.children ?? []), ...(s.children ?? [])]
|
|
124
|
+
s.header.models = [...new Set([...parts.flatMap((p) => p.header.models), ...s.header.models])]
|
|
125
|
+
s.header.lost = [...new Set([...parts.flatMap((p) => p.header.lost), ...s.header.lost])]
|
|
126
|
+
const started = [parts[0]!.header.startedAt, s.header.startedAt].filter((x): x is string => !!x).sort()[0]
|
|
127
|
+
if (started) s.header.startedAt = started
|
|
128
|
+
// The earlier transcripts are this session's own history, not a fork of another session.
|
|
129
|
+
if (s.header.parent?.kind === "fork" && priors.some((p) => s.header.parent!.id === `claude-code:${p}`)) delete s.header.parent
|
|
130
|
+
}
|
|
131
|
+
if (gone.length) s.header.lost.push(`earlier part of this session no longer on this computer (${gone.length} transcript${gone.length === 1 ? "" : "s"})`)
|
|
132
|
+
}
|
|
133
|
+
// The same transcripts, but the session is the app's: its id, its title.
|
|
134
|
+
s.header.tool = TOOL
|
|
135
|
+
s.header.sourceId = ref.sourceId
|
|
136
|
+
s.header.id = `${TOOL}:${ref.sourceId}`
|
|
137
|
+
if (ref.title) s.header.title = ref.title
|
|
138
|
+
return s
|
|
139
|
+
},
|
|
140
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
// Claude Desktop (the Claude app's Code tab): its sessions are Claude Code transcripts (~/.claude/projects/<slug>/<cli
|
|
2
|
+
// session id>.jsonl) plus one record per session that the app keeps:
|
|
3
|
+
// <app folder>/claude-code-sessions/<account>/<org>/local_<uuid>.json
|
|
4
|
+
// { sessionId: "local_<uuid>", cliSessionId, priorCliSessionIds?, cwd, title, titleSource, createdAt, lastActivityAt
|
|
5
|
+
// (epoch ms), isArchived, model, effort, permissionMode, ... }
|
|
6
|
+
// The record is what makes a session show in the app (with its title); the app reads new records when it starts. The same
|
|
7
|
+
// session can be kept under several accounts (copies drift): the one with the latest activity is the one that counts.
|
|
8
|
+
// Only reads here; the writer (writer.ts) adds a record.
|
|
9
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"
|
|
10
|
+
import { join, sep } from "node:path"
|
|
11
|
+
|
|
12
|
+
export const TOOL = "claude-desktop"
|
|
13
|
+
export const LABEL = "Claude Desktop"
|
|
14
|
+
|
|
15
|
+
type Env = Record<string, string | undefined>
|
|
16
|
+
|
|
17
|
+
/** The Claude app's own folder (macOS, Windows; Linux builds keep it under ~/.config). */
|
|
18
|
+
export function appDir(home: string, env: Env = process.env): string {
|
|
19
|
+
if (process.platform === "darwin") return join(home, "Library", "Application Support", "Claude")
|
|
20
|
+
if (process.platform === "win32") return join(env.APPDATA || join(home, "AppData", "Roaming"), "Claude")
|
|
21
|
+
return join(env.XDG_CONFIG_HOME || join(home, ".config"), "Claude")
|
|
22
|
+
}
|
|
23
|
+
export const sessionsRoot = (home: string, env?: Env) => join(appDir(home, env), "claude-code-sessions")
|
|
24
|
+
/**
|
|
25
|
+
* Every place the app keeps Code session records: the Claude account's profile, and its third-party profile ("Claude
|
|
26
|
+
* (third-party)", the app's folder with "-3p" added), which keeps its own sessions the same way.
|
|
27
|
+
*/
|
|
28
|
+
export const sessionsRoots = (home: string, env?: Env) => [sessionsRoot(home, env), join(`${appDir(home, env)}-3p`, "claude-code-sessions")]
|
|
29
|
+
/** The app's own MCP settings (its chats and its Code tab's local tools). */
|
|
30
|
+
export const desktopConfigPath = (home: string, env?: Env) => join(appDir(home, env), "claude_desktop_config.json")
|
|
31
|
+
|
|
32
|
+
export interface DesktopRecord {
|
|
33
|
+
sessionId: string
|
|
34
|
+
cliSessionId?: string
|
|
35
|
+
/** Earlier transcripts of the same session (a resume or a rewind started a new one). */
|
|
36
|
+
priorCliSessionIds: string[]
|
|
37
|
+
cwd?: string
|
|
38
|
+
title?: string
|
|
39
|
+
createdAt?: number
|
|
40
|
+
lastActivityAt?: number
|
|
41
|
+
isArchived: boolean
|
|
42
|
+
/** The record's file, and the account folder it is in. */
|
|
43
|
+
file: string
|
|
44
|
+
dir: string
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
|
48
|
+
const LOCAL = /^local_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
|
49
|
+
const ls = (dir: string): string[] => {
|
|
50
|
+
try {
|
|
51
|
+
return readdirSync(dir)
|
|
52
|
+
} catch {
|
|
53
|
+
return []
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
const num = (v: unknown) => (typeof v === "number" && Number.isFinite(v) ? v : undefined)
|
|
57
|
+
const str = (v: unknown) => (typeof v === "string" && v ? v : undefined)
|
|
58
|
+
|
|
59
|
+
/** Every account folder (`<account>/<org>`) the app keeps Code sessions in. */
|
|
60
|
+
export function accountDirs(home: string, env?: Env): string[] {
|
|
61
|
+
const out: string[] = []
|
|
62
|
+
for (const root of sessionsRoots(home, env))
|
|
63
|
+
for (const a of ls(root))
|
|
64
|
+
for (const o of ls(join(root, a))) {
|
|
65
|
+
const d = join(root, a, o)
|
|
66
|
+
try {
|
|
67
|
+
if (statSync(d).isDirectory()) out.push(d)
|
|
68
|
+
} catch {}
|
|
69
|
+
}
|
|
70
|
+
return out
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Every session the app keeps, once each (the copy with the latest activity), newest first. Never throws. */
|
|
74
|
+
export function readRecords(home: string, env?: Env): DesktopRecord[] {
|
|
75
|
+
const best = new Map<string, DesktopRecord>()
|
|
76
|
+
for (const dir of accountDirs(home, env)) {
|
|
77
|
+
for (const name of ls(dir)) {
|
|
78
|
+
if (!name.startsWith("local_") || !name.endsWith(".json")) continue
|
|
79
|
+
const file = join(dir, name)
|
|
80
|
+
let j: any
|
|
81
|
+
try {
|
|
82
|
+
j = JSON.parse(readFileSync(file, "utf8"))
|
|
83
|
+
} catch {
|
|
84
|
+
continue
|
|
85
|
+
}
|
|
86
|
+
const sessionId = str(j?.sessionId)
|
|
87
|
+
if (!sessionId || !LOCAL.test(sessionId)) continue
|
|
88
|
+
const cli = str(j.cliSessionId) ?? str(j.lastAssistantCliSessionId)
|
|
89
|
+
const r: DesktopRecord = {
|
|
90
|
+
sessionId,
|
|
91
|
+
...(cli && UUID.test(cli) ? { cliSessionId: cli } : {}),
|
|
92
|
+
priorCliSessionIds: Array.isArray(j.priorCliSessionIds) ? j.priorCliSessionIds.filter((x: unknown) => typeof x === "string" && UUID.test(x)) : [],
|
|
93
|
+
...(str(j.cwd) ? { cwd: j.cwd } : {}),
|
|
94
|
+
...(str(j.title) ? { title: j.title } : {}),
|
|
95
|
+
...(num(j.createdAt) !== undefined ? { createdAt: j.createdAt } : {}),
|
|
96
|
+
...(num(j.lastActivityAt) !== undefined ? { lastActivityAt: j.lastActivityAt } : {}),
|
|
97
|
+
isArchived: j.isArchived === true,
|
|
98
|
+
file,
|
|
99
|
+
dir,
|
|
100
|
+
}
|
|
101
|
+
const had = best.get(sessionId)
|
|
102
|
+
if (!had || (r.lastActivityAt ?? 0) > (had.lastActivityAt ?? 0)) best.set(sessionId, r)
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return [...best.values()].sort((a, b) => (b.lastActivityAt ?? 0) - (a.lastActivityAt ?? 0))
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** The transcripts the app's sessions use (now and before): Claude Code's reader leaves these to Claude Desktop. */
|
|
109
|
+
export function desktopTranscriptIds(home: string, env?: Env): Set<string> {
|
|
110
|
+
const ids = new Set<string>()
|
|
111
|
+
if (!sessionsRoots(home, env).some((r) => existsSync(r))) return ids
|
|
112
|
+
for (const r of readRecords(home, env)) {
|
|
113
|
+
if (r.cliSessionId) ids.add(r.cliSessionId)
|
|
114
|
+
for (const p of r.priorCliSessionIds) ids.add(p)
|
|
115
|
+
}
|
|
116
|
+
return ids
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** The account the app says it is signed in to (its config.json), if it says. */
|
|
120
|
+
export function signedInAccount(home: string, env?: Env): string | undefined {
|
|
121
|
+
try {
|
|
122
|
+
const id = JSON.parse(readFileSync(join(appDir(home, env), "config.json"), "utf8"))?.lastKnownAccountUuid
|
|
123
|
+
return typeof id === "string" && UUID.test(id) ? id : undefined
|
|
124
|
+
} catch {
|
|
125
|
+
return undefined
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** The mode the app was last chosen to start in ("1p": the Claude account, "3p": third-party), as its third-party profile keeps it. */
|
|
130
|
+
export function savedMode(home: string, env?: Env): "1p" | "3p" | undefined {
|
|
131
|
+
try {
|
|
132
|
+
const m = JSON.parse(readFileSync(join(`${appDir(home, env)}-3p`, "claude_desktop_config.json"), "utf8"))?.deploymentMode
|
|
133
|
+
return m === "1p" || m === "3p" ? m : undefined
|
|
134
|
+
} catch {
|
|
135
|
+
return undefined
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* The account folder in use. In third-party mode (chosen, or its sessions are the newest), the third-party profile's
|
|
141
|
+
* folder with the latest session. Otherwise under the account the app is signed in to when it has a folder there (its org
|
|
142
|
+
* with the latest session, or its only org); otherwise the one whose sessions were active last.
|
|
143
|
+
*/
|
|
144
|
+
export function activeAccountDir(home: string, env?: Env): string | undefined {
|
|
145
|
+
const records = readRecords(home, env)
|
|
146
|
+
const thirdParty = sessionsRoots(home, env)[1]! + sep
|
|
147
|
+
const newest = records[0]
|
|
148
|
+
if (savedMode(home, env) === "3p" || newest?.dir.startsWith(thirdParty)) {
|
|
149
|
+
const there = records.find((r) => r.dir.startsWith(thirdParty))
|
|
150
|
+
if (there) return there.dir
|
|
151
|
+
}
|
|
152
|
+
const account = signedInAccount(home, env)
|
|
153
|
+
if (account) {
|
|
154
|
+
const mine = accountDirs(home, env).filter((d) => d.startsWith(join(sessionsRoot(home, env), account) + sep))
|
|
155
|
+
const latest = records.find((r) => mine.includes(r.dir))
|
|
156
|
+
if (latest) return latest.dir
|
|
157
|
+
if (mine.length === 1) return mine[0]
|
|
158
|
+
}
|
|
159
|
+
let best: { dir: string; at: number } | undefined
|
|
160
|
+
for (const r of records) if (!best || (r.lastActivityAt ?? 0) > best.at) best = { dir: r.dir, at: r.lastActivityAt ?? 0 }
|
|
161
|
+
if (best) return best.dir
|
|
162
|
+
// No session yet in any account: the only account folder there is, if there is exactly one.
|
|
163
|
+
const dirs = accountDirs(home, env)
|
|
164
|
+
return dirs.length === 1 ? dirs[0] : undefined
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** The newest record in an account folder, as the app wrote it: its model and effort are the app's current ones. */
|
|
168
|
+
export function newestRecordIn(dir: string): Record<string, unknown> | undefined {
|
|
169
|
+
let best: { j: Record<string, unknown>; at: number } | undefined
|
|
170
|
+
for (const name of ls(dir)) {
|
|
171
|
+
if (!name.startsWith("local_") || !name.endsWith(".json")) continue
|
|
172
|
+
try {
|
|
173
|
+
const j = JSON.parse(readFileSync(join(dir, name), "utf8"))
|
|
174
|
+
const at = num(j?.lastActivityAt) ?? 0
|
|
175
|
+
if (!best || at > best.at) best = { j, at }
|
|
176
|
+
} catch {}
|
|
177
|
+
}
|
|
178
|
+
return best?.j
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** The Claude Code the app runs its sessions with: the newest version folder in `<app folder>/claude-code/`. */
|
|
182
|
+
export function bundledVersion(home: string, env?: Env): string | undefined {
|
|
183
|
+
const parse = (v: string) => (/^\d+\.\d+\.\d+$/.test(v) ? v.split(".").map(Number) : undefined)
|
|
184
|
+
let best: { v: string; n: number[] } | undefined
|
|
185
|
+
for (const name of ls(join(appDir(home, env), "claude-code"))) {
|
|
186
|
+
const n = parse(name)
|
|
187
|
+
if (!n) continue
|
|
188
|
+
if (!best || n[0]! > best.n[0]! || (n[0] === best.n[0] && (n[1]! > best.n[1]! || (n[1] === best.n[1] && n[2]! > best.n[2]!)))) best = { v: name, n }
|
|
189
|
+
}
|
|
190
|
+
return best?.v
|
|
191
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// A session continued in Claude Desktop (the Claude app's Code tab): a new Claude Code transcript (claude-code/writer.ts),
|
|
2
|
+
// plus the app's record for it in the account folder in use (records.ts), so it shows in the app's sessions with its title.
|
|
3
|
+
// The record copies the app's current model and effort from its newest record; it never carries permissions (a moved
|
|
4
|
+
// session starts in the default permission mode). The app reads new records when it starts: quit and open it again.
|
|
5
|
+
import { randomUUID } from "node:crypto"
|
|
6
|
+
import { chmodSync, existsSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs"
|
|
7
|
+
import { join } from "node:path"
|
|
8
|
+
import { claudeCodeNativeWriter, claudeCodeWriter } from "../claude-code/writer.ts"
|
|
9
|
+
import type { Readiness, WriteOptions, WriteResult, Writer } from "../types.ts"
|
|
10
|
+
import { countTurns } from "../../session/util.ts"
|
|
11
|
+
import type { Session } from "../../session/types.ts"
|
|
12
|
+
import { activeAccountDir, bundledVersion, LABEL, newestRecordIn, sessionsRoots, TOOL } from "./records.ts"
|
|
13
|
+
|
|
14
|
+
const isDir = (p: string) => {
|
|
15
|
+
try {
|
|
16
|
+
return statSync(p).isDirectory()
|
|
17
|
+
} catch {
|
|
18
|
+
return false
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
const real = (p: string) => {
|
|
22
|
+
try {
|
|
23
|
+
return realpathSync(p)
|
|
24
|
+
} catch {
|
|
25
|
+
return p
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
const OPEN = process.platform === "darwin" ? "open -a Claude" : "the Claude app"
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The transcript writer for the app: Claude Code's, checked against and stamped with the Claude Code the app bundles (not
|
|
32
|
+
* the one on PATH, which can differ); the installed CLI's when the app keeps no bundled copy where brain looks.
|
|
33
|
+
*/
|
|
34
|
+
const transcriptWriter = (home: string) => {
|
|
35
|
+
const v = bundledVersion(home)
|
|
36
|
+
return v ? claudeCodeWriter({ version: v }) : claudeCodeNativeWriter
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function claudeDesktopWriter(): Writer {
|
|
40
|
+
return {
|
|
41
|
+
tool: TOOL,
|
|
42
|
+
label: LABEL,
|
|
43
|
+
way: "native",
|
|
44
|
+
|
|
45
|
+
async ready(w: WriteOptions): Promise<Readiness> {
|
|
46
|
+
if (!sessionsRoots(w.home).some((r) => existsSync(r))) return { ok: false, why: "Claude Desktop is not on this computer, or its Code tab has not been opened yet" }
|
|
47
|
+
if (!activeAccountDir(w.home)) return { ok: false, why: "Claude Desktop has no account with Code sessions here yet: open its Code tab once" }
|
|
48
|
+
if (!isDir(w.cwd)) return { ok: false, why: `${w.cwd} is not a folder; Claude Desktop opens a session in the folder it belongs to` }
|
|
49
|
+
// The transcript's format: the same check Claude Code's own writer makes, against the app's Claude Code.
|
|
50
|
+
const t = await transcriptWriter(w.home).ready(w)
|
|
51
|
+
if (!t.ok) return { ok: false, why: t.why.replace(/^Claude Code is not installed.*/, "Claude Desktop's Claude Code was not found, and `claude --version` did not run") }
|
|
52
|
+
return { ok: true }
|
|
53
|
+
},
|
|
54
|
+
|
|
55
|
+
async write(session: Session, w: WriteOptions): Promise<WriteResult> {
|
|
56
|
+
const dir = activeAccountDir(w.home)
|
|
57
|
+
if (!dir) throw new Error("Claude Desktop has no account with Code sessions here yet: open its Code tab once")
|
|
58
|
+
// The transcript: the same one Claude Code itself would keep (read back and checked before it is kept).
|
|
59
|
+
const t = await transcriptWriter(w.home).write(session, w)
|
|
60
|
+
const localId = `local_${randomUUID()}`
|
|
61
|
+
const recordPath = join(dir, `${localId}.json`)
|
|
62
|
+
const title = (session.header.title ?? "").replace(/\s+/g, " ").trim().slice(0, 200) || "Continued with brain"
|
|
63
|
+
if (!w.dryRun) {
|
|
64
|
+
const template = newestRecordIn(dir) ?? {}
|
|
65
|
+
const cwd = real(w.cwd)
|
|
66
|
+
const now = Date.now()
|
|
67
|
+
const record = {
|
|
68
|
+
sessionId: localId,
|
|
69
|
+
cliSessionId: t.sessionId,
|
|
70
|
+
cwd,
|
|
71
|
+
originCwd: cwd,
|
|
72
|
+
lastFocusedAt: now,
|
|
73
|
+
createdAt: now,
|
|
74
|
+
lastActivityAt: now,
|
|
75
|
+
...(typeof template.model === "string" ? { model: template.model } : {}),
|
|
76
|
+
...(typeof template.effort === "string" ? { effort: template.effort } : {}),
|
|
77
|
+
isArchived: false,
|
|
78
|
+
title,
|
|
79
|
+
titleSource: "user",
|
|
80
|
+
permissionMode: "default",
|
|
81
|
+
remoteMcpServersConfig: [],
|
|
82
|
+
alwaysAllowedReasons: [],
|
|
83
|
+
sessionPermissionUpdates: [],
|
|
84
|
+
classifierSummaryEnabled: true,
|
|
85
|
+
spawnSeed: {},
|
|
86
|
+
completedTurns: countTurns(session).user,
|
|
87
|
+
}
|
|
88
|
+
try {
|
|
89
|
+
// "wx": a new record only; an existing one is never touched.
|
|
90
|
+
writeFileSync(recordPath, `${JSON.stringify(record, null, 2)}\n`, { flag: "wx", mode: 0o600 })
|
|
91
|
+
chmodSync(recordPath, 0o600)
|
|
92
|
+
} catch (e) {
|
|
93
|
+
// Without its record the transcript would be a Claude Code session nobody asked for: removed.
|
|
94
|
+
for (const f of t.written) rmSync(f, { force: true })
|
|
95
|
+
throw e
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return {
|
|
99
|
+
tool: TOOL,
|
|
100
|
+
way: "native",
|
|
101
|
+
sessionId: localId,
|
|
102
|
+
written: [...t.written, recordPath],
|
|
103
|
+
open: OPEN,
|
|
104
|
+
note: `quit Claude and open it again (it reads new sessions when it starts); "${title.slice(0, 80)}" is then in the Code tab's sessions`,
|
|
105
|
+
lost: t.lost,
|
|
106
|
+
dryRun: !!w.dryRun,
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export const claudeDesktopNativeWriter = claudeDesktopWriter()
|