@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.
Files changed (39) hide show
  1. package/README.md +41 -1
  2. package/package.json +1 -1
  3. package/src/adapters/amazon-q-ide/writer.ts +3 -1
  4. package/src/adapters/antigravity/reader.ts +25 -7
  5. package/src/adapters/claude-code/reader.ts +4 -0
  6. package/src/adapters/claude-desktop/index.ts +8 -0
  7. package/src/adapters/claude-desktop/reader.ts +140 -0
  8. package/src/adapters/claude-desktop/records.ts +191 -0
  9. package/src/adapters/claude-desktop/writer.ts +112 -0
  10. package/src/adapters/codex/reader.ts +19 -5
  11. package/src/adapters/codex/records.ts +17 -0
  12. package/src/adapters/codex/writer.ts +20 -7
  13. package/src/adapters/codex-desktop/index.ts +38 -0
  14. package/src/adapters/index.ts +4 -0
  15. package/src/cli.ts +79 -71
  16. package/src/dashboard/cli.ts +17 -2
  17. package/src/dashboard/report.ts +98 -5
  18. package/src/dashboard/sessions.ts +127 -0
  19. package/src/dashboard/state.ts +64 -5
  20. package/src/gateway/common.ts +8 -0
  21. package/src/gateway/connect.ts +33 -3
  22. package/src/gateway/connectors/antigravity.ts +205 -5
  23. package/src/gateway/connectors/claude-desktop.ts +169 -0
  24. package/src/gateway/connectors/index.ts +2 -0
  25. package/src/handoff.ts +18 -2
  26. package/src/hooks.ts +2 -2
  27. package/src/lock.ts +3 -0
  28. package/src/memory/mcp-install.ts +9 -0
  29. package/src/memory/sync.ts +25 -3
  30. package/src/moves.ts +402 -0
  31. package/src/service.ts +490 -0
  32. package/src/session/open.ts +82 -0
  33. package/src/session/ops.ts +111 -0
  34. package/src/settings/account.ts +65 -10
  35. package/src/settings/catalog.ts +20 -2
  36. package/src/settings/cli.ts +18 -3
  37. package/src/settings/env.ts +2 -0
  38. package/src/settings/pair.ts +3 -0
  39. 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, Antigravity, Amazon Q, Kiro, Auggie, Tabnine, Devin and Muse Code (their vendor's own
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.4",
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
- open: `${editor} ${shellQuote(cwd)} # Amazon Q reopens "${plan.tab.title as string}" when the folder opens; it is also in Amazon Q's History`,
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
- ...(typeof r.title === "string" && r.title.trim() ? { title: r.title } : {}),
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 newer copy.
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
- if (!prev || (ref.updatedAt ?? "") > (prev.updatedAt ?? "")) found.set(id, ref)
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
- const s = readSummaries(join(dirname(dirname(ref.path)), "conversation_summaries.db"), id).get(id)
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()