@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
@@ -49,12 +49,23 @@ export interface BrainReport {
49
49
  contextEdit: boolean
50
50
  connected: Array<{ tool: string; label: string; since: string | null }>
51
51
  }
52
- memory: { available: true; projects: number; items: number } | { available: false }
52
+ memory: { available: true; projects: number; items: number; projectList?: Array<{ name: string; items: number }> } | { available: false }
53
53
  conversions: Conversion[]
54
54
  /** byMonth: conversions per month, UTC "YYYY-MM", the latest twelve months that have any. */
55
55
  totals: { conversions: number; byMonth: Record<string, number> }
56
56
  /** Every setting on this computer and its state (docs/settings-contract.md, section 3). Absent when it could not be read. */
57
57
  settings?: SettingsSection
58
+ // docs/sessions-contract.md, section 3: what the dashboard needs to show every feature. Each is optional.
59
+ /** Where a session can go from this computer, as `brain session targets` says. */
60
+ targets?: Array<{ tool: string; label: string; ways: Array<{ way: string; ready: boolean; why?: string }> }>
61
+ /** How often each kind of digested tool output is asked back (`brain tokens lookups`). */
62
+ tokens?: { lookups: Array<{ kind: string; stubbed: number; expanded: number }> }
63
+ /** Team memory (`brain org`): the server's host only, never its token. */
64
+ org?: { joined: true; server: string; scope: string | null } | { joined: false }
65
+ /** What was last sent of the session list (sessions.ts). */
66
+ sessions?: { total: number; uploadedAt: string | null; off: boolean }
67
+ /** Moves from the dashboard (moves.ts): this computer's setting, and how many wait here for the person. */
68
+ moves?: { setting: string; waiting: number }
58
69
  }
59
70
 
60
71
  export interface SettingsRow {
@@ -76,12 +87,16 @@ export interface SettingsRow {
76
87
  error?: string
77
88
  notes?: string[]
78
89
  remote: boolean
90
+ /** A change made here to this setting (any level) not sent to the account yet: being the later write, it will win. */
91
+ pending?: true
79
92
  }
80
93
 
81
94
  export interface SettingsSection {
82
95
  paired: boolean
83
96
  lastSyncAt: string | null
84
97
  waiting: number
98
+ /** brain's background service on this computer (docs/settings-contract.md, section 6). */
99
+ service: { installed: boolean; running: boolean; lastRunAt: string | null }
85
100
  rows: SettingsRow[]
86
101
  }
87
102
 
@@ -97,6 +112,9 @@ export async function settingsSection(home: string, env: Env): Promise<SettingsS
97
112
  const rows = await withTimeout(listSettings(home, env), 5_000)
98
113
  if (!rows) return undefined
99
114
  const st = exchangeState(home)
115
+ const { serviceState } = await import("../service.ts")
116
+ const { readSettingsFile } = await import("../settings/store.ts")
117
+ const unsent = new Set(readSettingsFile(home).doc.outbox.map((c) => c.id))
100
118
  const short = (v: unknown, type: { kind: string; choices?: string[] }): boolean | number | string | null => {
101
119
  if (v === undefined || v === null) return null
102
120
  if (typeof v === "boolean" || typeof v === "number") return v
@@ -108,6 +126,7 @@ export async function settingsSection(home: string, env: Env): Promise<SettingsS
108
126
  paired: st.paired,
109
127
  lastSyncAt: st.lastSyncAt ?? null,
110
128
  waiting: st.waiting,
129
+ service: serviceState(home),
111
130
  rows: rows.slice(0, 400).map((r): SettingsRow => {
112
131
  const t = r.family.type
113
132
  return {
@@ -126,6 +145,7 @@ export async function settingsSection(home: string, env: Env): Promise<SettingsS
126
145
  ...(r.error ? { error: clip(cleanText(r.error, home), 300) } : {}),
127
146
  ...(r.notes.length ? { notes: r.notes.slice(0, 5).map((n) => clip(cleanText(n, home), 300)) } : {}),
128
147
  remote: r.family.remote === "yes",
148
+ ...(st.paired && unsent.has(r.id) ? { pending: true as const } : {}),
129
149
  }
130
150
  }),
131
151
  }
@@ -226,7 +246,10 @@ async function memorySummary(home: string, env: Env): Promise<BrainReport["memor
226
246
  const s = await mem.status()
227
247
  if (!s.available) return { available: false as const }
228
248
  const projects = await mem.projects()
229
- return { available: true as const, projects: projects.length, items: projects.reduce((n, p) => n + (Number(p.items) || 0), 0) }
249
+ // A project's name as memory labels it; a path in it is written from the home folder (~).
250
+ const name = (l: string) => clip(String(l ?? "").replaceAll(home, "~").replace(/[\u0000-\u001f\u007f]/g, " ").trim(), 80) || "(unnamed)"
251
+ const projectList = [...projects].sort((a, b) => (Number(b.items) || 0) - (Number(a.items) || 0)).slice(0, 50).map((p) => ({ name: name(p.label), items: Math.max(0, Math.floor(Number(p.items) || 0)) }))
252
+ return { available: true as const, projects: projects.length, items: projects.reduce((n, p) => n + (Number(p.items) || 0), 0), projectList }
230
253
  })(),
231
254
  5_000,
232
255
  )
@@ -294,6 +317,11 @@ export async function harnesses(home: string, counts: Record<string, ToolCount>
294
317
  }
295
318
  for (const t of MCP_TOOLS) add(mcpToolId(t.id), t.label, mcpToolHere(t, home, env) || mcpInstalled.get(mcpToolId(t.id)) === true)
296
319
  for (const id of HOOK_TOOLS) add(id, toolLabel(id), hookOn(id))
320
+ // The Codex app (its threads share ~/.codex with the CLI).
321
+ try {
322
+ const { codexAppHere } = await import("../adapters/codex/records.ts")
323
+ add("codex-desktop", "Codex Desktop", codexAppHere(home))
324
+ } catch {}
297
325
  const mcpIds = new Set(MCP_TOOLS.map((t) => mcpToolId(t.id)))
298
326
  const connectorIds = new Set(CONNECTORS.map((c) => c.id))
299
327
  return [...known.entries()]
@@ -303,9 +331,11 @@ export async function harnesses(home: string, counts: Record<string, ToolCount>
303
331
  label: clip(k.label, 80),
304
332
  sessions: counts?.[id] ? counts[id]!.sessions : null,
305
333
  lastSessionAt: counts?.[id]?.lastSessionAt ?? null,
306
- gateway: connected[id] ? "connected" : (ownClient(id, home, env) ?? (connectorIds.has(id) ? "available" : "unsupported")),
307
- mcp: mcpIds.has(id) ? mcpInstalled.get(id) === true : null,
308
- hooks: HOOK_TOOLS.includes(id) ? hookOn(id) : null,
334
+ // The Codex app reads Codex's own settings (~/.codex): its gateway and MCP tools are Codex's.
335
+ gateway: id === "codex-desktop" ? (connected.codex ? "connected" : "available") : connected[id] ? "connected" : (ownClient(id, home, env) ?? (connectorIds.has(id) ? "available" : "unsupported")),
336
+ mcp: id === "codex-desktop" ? mcpInstalled.get("codex") === true : mcpIds.has(id) ? mcpInstalled.get(id) === true : null,
337
+ // Claude Desktop's Code tab runs Claude Code's hooks.
338
+ hooks: HOOK_TOOLS.includes(id) ? hookOn(id) : id === "claude-desktop" ? hookOn("claude-code") : id === "codex-desktop" && HOOK_TOOLS.includes("codex") ? hookOn("codex") : null,
309
339
  }))
310
340
  .sort((a, b) => a.label.localeCompare(b.label) || a.id.localeCompare(b.id))
311
341
  }
@@ -344,9 +374,72 @@ export async function buildReport(home: string, o: { counts?: Record<string, Too
344
374
  conversions: list,
345
375
  totals: { conversions: total, byMonth },
346
376
  ...(settings ? { settings } : {}),
377
+ ...(await featureParts(home)),
347
378
  }
348
379
  }
349
380
 
381
+ /** Where a session can go from this computer: each tool brain writes, and whether each way is ready now (dry run). */
382
+ export async function targetsPart(home: string): Promise<BrainReport["targets"]> {
383
+ const { WRITERS, readerFor } = await import("../adapters/index.ts")
384
+ const { cleanText } = await import("../settings/catalog.ts")
385
+ const byTool = new Map<string, { tool: string; label: string; ways: Array<{ way: string; ready: boolean; why?: string }> }>()
386
+ await Promise.all(
387
+ WRITERS.map(async (w) => {
388
+ let ready = false
389
+ let why: string | undefined
390
+ try {
391
+ const r = await w.ready({ home, cwd: home, dryRun: true })
392
+ ready = r.ok
393
+ if (!r.ok) why = clip(cleanText(r.why, home), 200)
394
+ } catch (e) {
395
+ why = clip(cleanText(String((e as Error)?.message ?? e), home), 200)
396
+ }
397
+ const t = byTool.get(w.tool) ?? { tool: w.tool, label: clip(readerFor(w.tool === "cursor" ? "cursor-ide" : w.tool)?.label ?? w.label ?? w.tool, 60), ways: [] }
398
+ t.ways.push({ way: w.way, ready, ...(why ? { why } : {}) })
399
+ byTool.set(w.tool, t)
400
+ }),
401
+ )
402
+ const order = new Map(WRITERS.map((w, i) => [w.tool, i]))
403
+ return [...byTool.values()].sort((a, b) => (order.get(a.tool) ?? 0) - (order.get(b.tool) ?? 0)).slice(0, 60)
404
+ }
405
+
406
+ /** Works out where a session can go from here now and keeps it for the reports that follow (explicit commands only). */
407
+ export async function refreshTargets(home: string, timeoutMs = 15_000): Promise<void> {
408
+ const list = await withTimeout(targetsPart(home), timeoutMs)
409
+ if (list?.length) updateCache(home, (c) => {
410
+ c.targets = { at: new Date().toISOString(), list }
411
+ })
412
+ }
413
+
414
+ /** The additions the dashboard's every-feature panel reads (docs/sessions-contract.md, section 3); each one alone may fail. */
415
+ async function featureParts(home: string): Promise<Pick<BrainReport, "targets" | "tokens" | "org" | "sessions" | "moves">> {
416
+ const out: Pick<BrainReport, "targets" | "tokens" | "org" | "sessions" | "moves"> = {}
417
+ // From the last `brain dashboard` / `brain sync`: a writer's check may run its tool (`--version`), never in the background.
418
+ const targets = readCache(home).targets?.list
419
+ if (Array.isArray(targets) && targets.length) out.targets = targets.slice(0, 60)
420
+ try {
421
+ const { lookupRates } = await import("../memory/lookups.ts")
422
+ out.tokens = { lookups: lookupRates(home).slice(0, 30).map((r) => ({ kind: clip(r.kind, 40), stubbed: r.stubbed, expanded: r.expanded })) }
423
+ } catch {}
424
+ try {
425
+ const cfg = readOrgConfig(home)
426
+ let host = ""
427
+ try {
428
+ host = cfg ? new URL(cfg.server).host : ""
429
+ } catch {}
430
+ out.org = cfg && host ? { joined: true, server: clip(host, 120), scope: typeof cfg.defaultScope === "string" ? clip(cfg.defaultScope, 20) : null } : { joined: false }
431
+ } catch {}
432
+ try {
433
+ const { sessionsState } = await import("./sessions.ts")
434
+ out.sessions = sessionsState(home)
435
+ } catch {}
436
+ try {
437
+ const { movesState } = await import("../moves.ts")
438
+ out.moves = movesState(home)
439
+ } catch {}
440
+ return out
441
+ }
442
+
350
443
  /** The report as it is sent: at most MAX_BYTES of JSON, the oldest conversions dropped first if it is bigger. */
351
444
  export function reportBody(r: BrainReport, maxBytes = MAX_BYTES): string {
352
445
  let body = JSON.stringify(r)
@@ -0,0 +1,127 @@
1
+ // This computer's session list, for the dashboard's Sessions panel (docs/sessions-contract.md, section 1): every session
2
+ // brain can list, by id, tool, title, project folder name and when; never chat text, never a full path. Sent with the
3
+ // computer's token (only a paired computer sends it), from the background service when the list changed (looked at no
4
+ // more than every 5 minutes) and from `brain dashboard` / `brain sync`.
5
+ import { createHash } from "node:crypto"
6
+ import { basename } from "node:path"
7
+ import type { Env } from "../gateway/common.ts"
8
+ import { cleanText } from "../settings/catalog.ts"
9
+ import { sessionsListOn } from "../settings/values.ts"
10
+ import { gatewayUrl, safeUrl, sendingOff } from "./send.ts"
11
+ import { machineId, readCache, readMachineToken, updateCache } from "./state.ts"
12
+
13
+ export const SESSIONS_PATH = "/api/brain/sessions"
14
+ /** The background service looks at the list (and sends it when it changed) at most this often. */
15
+ export const SESSIONS_EVERY_MS = 5 * 60_000
16
+ export const MAX_SESSIONS = 20_000
17
+ const TOOL = /^[a-z0-9][a-z0-9-]{0,39}$/
18
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: control characters are what it finds
19
+ const CONTROL = /[\u0000-\u001f\u007f-\u009f]/
20
+ /** Control, bidirectional and zero-width characters: never in what the dashboard shows. */
21
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: control characters are what it removes
22
+ const UNSHOWN = /[\u0000-\u001f\u007f-\u009f\u061c\u200b-\u200f\u202a-\u202e\u2060-\u2069\ufeff]/g
23
+ /** The upload stays under the gateway's 4 MB: sessions past this many bytes are left out (the newest are kept). */
24
+ export const MAX_UPLOAD_BYTES = 3_900_000
25
+
26
+ export interface ListedSession {
27
+ id: string
28
+ tool: string
29
+ title: string | null
30
+ project: string | null
31
+ updatedAt: string | null
32
+ }
33
+
34
+ export interface SessionsUpload {
35
+ machine: string
36
+ sentAt: string
37
+ total: number
38
+ off: boolean
39
+ sessions: ListedSession[]
40
+ }
41
+
42
+ const clip = (s: string, n: number) => (s.length <= n ? s : `${s.slice(0, n - 1)}…`)
43
+ const iso = (v: unknown): string | null => {
44
+ const t = typeof v === "string" ? Date.parse(v) : NaN
45
+ return Number.isFinite(t) ? new Date(t).toISOString() : null
46
+ }
47
+
48
+ /** The upload as it would go now: the list (or nothing, with `off`, when reports.sessions is off here). */
49
+ export async function buildSessionsUpload(home: string, o: { now?: Date } = {}): Promise<SessionsUpload> {
50
+ const base = { machine: machineId(home), sentAt: (o.now ?? new Date()).toISOString() }
51
+ if (!sessionsListOn(home)) return { ...base, total: 0, off: true, sessions: [] }
52
+ const { listSessions } = await import("../session/ops.ts")
53
+ const { refs } = await listSessions(home)
54
+ const sessions: ListedSession[] = []
55
+ let bytes = 200
56
+ for (const r of refs) {
57
+ if (sessions.length >= MAX_SESSIONS) break
58
+ const id = `${r.tool}:${r.sourceId}`
59
+ if (!TOOL.test(r.tool) || id.length > 200 || CONTROL.test(id) || /[\u061c\u200b-\u200f\u202a-\u202e\u2060-\u2069\ufeff]/.test(id)) continue
60
+ const title = r.title ? clip(cleanText(r.title, home).replace(UNSHOWN, " ").replace(/\s+/g, " ").trim(), 120) : ""
61
+ // The project is its folder's name; a session in the home folder itself has none worth naming.
62
+ const project = r.cwd && r.cwd !== home ? clip(basename(r.cwd).replace(UNSHOWN, " ").replace(/\s+/g, " ").trim(), 80) : ""
63
+ const s: ListedSession = { id, tool: r.tool, title: title || null, project: project || null, updatedAt: iso(r.updatedAt) }
64
+ bytes += Buffer.byteLength(JSON.stringify(s)) + 1
65
+ if (bytes > MAX_UPLOAD_BYTES) break
66
+ sessions.push(s)
67
+ }
68
+ return { ...base, total: refs.length, off: false, sessions }
69
+ }
70
+
71
+ /** What changes when the list changes (not when it was sent). */
72
+ export const sessionsHash = (u: SessionsUpload) => createHash("sha256").update(JSON.stringify([u.off, u.total, u.sessions])).digest("hex").slice(0, 32)
73
+
74
+ export type SessionsSent = { sent: true; total: number } | { sent: false; why: string }
75
+
76
+ /**
77
+ * Sends the list when it is due: `force` (brain dashboard, brain sync) always; otherwise when SESSIONS_EVERY_MS passed
78
+ * since the last look and the list changed. Only a paired computer with reports on. Never throws.
79
+ */
80
+ export async function sendSessions(home: string, o: { env?: Env; force?: boolean; fetchImpl?: typeof fetch; timeoutMs?: number; now?: number } = {}): Promise<SessionsSent> {
81
+ const env = o.env ?? process.env
82
+ try {
83
+ const token = readMachineToken(home)
84
+ if (!token) return { sent: false, why: "not paired (brain pair)" }
85
+ const off = sendingOff(home, env)
86
+ if (off) return { sent: false, why: off }
87
+ const now = o.now ?? Date.now()
88
+ const cache = readCache(home).sessions ?? {}
89
+ if (!o.force && cache.lastLookMs && now - cache.lastLookMs < SESSIONS_EVERY_MS) return { sent: false, why: "not due" }
90
+ const upload = await buildSessionsUpload(home)
91
+ const hash = sessionsHash(upload)
92
+ updateCache(home, (c) => {
93
+ c.sessions = { ...c.sessions, lastLookMs: now }
94
+ })
95
+ if (!o.force && hash === cache.hash && cache.lastSentAt) return { sent: false, why: "unchanged" }
96
+ const url = `${gatewayUrl(home)}${SESSIONS_PATH}`
97
+ if (!safeUrl(url)) return { sent: false, why: `the gateway address ${gatewayUrl(home)} is not https; the token is never sent over it` }
98
+ let why: string | undefined
99
+ try {
100
+ const res = await (o.fetchImpl ?? fetch)(url, {
101
+ method: "PUT",
102
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
103
+ body: JSON.stringify(upload),
104
+ redirect: "manual",
105
+ signal: AbortSignal.timeout(o.timeoutMs ?? 20_000),
106
+ })
107
+ await res.text().catch(() => "")
108
+ if (!res.ok) why = res.status === 401 ? "your account no longer takes this computer's token: pair it again with `brain pair`" : res.status === 429 ? "too many uploads for now; the next one carries everything" : `the gateway answered HTTP ${res.status}`
109
+ } catch (e) {
110
+ const n = (e as Error)?.name
111
+ why = n === "TimeoutError" || n === "AbortError" ? "the gateway did not answer in time" : `could not reach ${gatewayUrl(home)}`
112
+ }
113
+ const at = new Date(now).toISOString()
114
+ updateCache(home, (c) => {
115
+ c.sessions = why ? { ...c.sessions, lastError: why } : { lastLookMs: now, hash, lastSentAt: at, total: upload.total, off: upload.off }
116
+ })
117
+ return why ? { sent: false, why } : { sent: true, total: upload.total }
118
+ } catch (e) {
119
+ return { sent: false, why: `the session list could not be made: ${String((e as Error)?.message ?? e).slice(0, 200)}` }
120
+ }
121
+ }
122
+
123
+ /** What the report says about it: what was last sent. */
124
+ export function sessionsState(home: string): { total: number; uploadedAt: string | null; off: boolean } {
125
+ const c = readCache(home).sessions ?? {}
126
+ return { total: typeof c.total === "number" ? c.total : 0, uploadedAt: typeof c.lastSentAt === "string" ? c.lastSentAt : null, off: c.off === true || !sessionsListOn(home) }
127
+ }
@@ -8,8 +8,9 @@
8
8
  // (when the last report went out, a push that is waiting to run).
9
9
  // Every write re-reads the file first and changes only its own fields, then replaces the file atomically.
10
10
  import { createHash } from "node:crypto"
11
- import { chmodSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"
11
+ import { chmodSync, linkSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"
12
12
  import { dirname, join } from "node:path"
13
+ import { withBrainLockSync } from "../lock.ts"
13
14
 
14
15
  export const brainDir = (home: string) => join(home, ".brain")
15
16
  export const machinePath = (home: string) => join(brainDir(home), "machine.json")
@@ -41,6 +42,17 @@ export interface DashboardCache {
41
42
  /** Why the last attempt failed (cleared by a success). */
42
43
  lastError?: string
43
44
  }
45
+ /** Where a session can go from here, from the last `brain dashboard` / `brain sync` (each writer's check can run its tool). */
46
+ targets?: { at: string; list: Array<{ tool: string; label: string; ways: Array<{ way: string; ready: boolean; why?: string }> }> }
47
+ /** The session list for the dashboard (sessions.ts): when it was last looked at, and what was last sent. */
48
+ sessions?: {
49
+ lastLookMs?: number
50
+ hash?: string
51
+ lastSentAt?: string
52
+ total?: number
53
+ off?: boolean
54
+ lastError?: string
55
+ }
44
56
  }
45
57
 
46
58
  function readJson<T extends object>(path: string): T {
@@ -133,9 +145,40 @@ export function hardwareHash(env: Record<string, string | undefined> = process.e
133
145
  * another computer (its hardware hash differs) gets a new id too, and that computer's token is dropped here: the copy is
134
146
  * a computer of its own, and is paired on its own.
135
147
  */
148
+ /**
149
+ * Writes `value` to `path` only when there is no file there yet (link of a finished file: never a half-written one). True
150
+ * when this call made it; false when one was there already.
151
+ */
152
+ function createPrivateJson(path: string, value: unknown): boolean {
153
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 })
154
+ const tmp = `${path}.${process.pid}.${Math.random().toString(36).slice(2, 8)}.brain-new`
155
+ try {
156
+ writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 })
157
+ chmodSync(tmp, 0o600)
158
+ linkSync(tmp, path)
159
+ return true
160
+ } catch (e) {
161
+ if ((e as NodeJS.ErrnoException)?.code === "EEXIST") return false
162
+ throw e
163
+ } finally {
164
+ rmSync(tmp, { force: true })
165
+ }
166
+ }
167
+
136
168
  export function machineId(home: string): string {
137
169
  const m = readJson<{ id?: unknown; createdAt?: unknown; hw?: unknown }>(machinePath(home))
138
170
  const here = hardwareHash()
171
+ const fresh = (id: string) => ({ id, createdAt: new Date().toISOString(), ...(here ? { hw: here } : {}) })
172
+ if (m.id === undefined) {
173
+ // The first id: made once, even when two brains start at the same moment (`brain gateway login` leaves a report
174
+ // starting in the background as `brain pair` begins): whichever makes the file first, both use its id.
175
+ const id = crypto.randomUUID()
176
+ try {
177
+ if (createPrivateJson(machinePath(home), fresh(id))) return id
178
+ const made = readJson<{ id?: unknown }>(machinePath(home))
179
+ if (typeof made.id === "string" && UUID.test(made.id)) return made.id
180
+ } catch {}
181
+ }
139
182
  if (typeof m.id === "string" && UUID.test(m.id)) {
140
183
  if (!here || m.hw === here) return m.id
141
184
  if (m.hw === undefined) {
@@ -149,9 +192,25 @@ export function machineId(home: string): string {
149
192
  rmSync(machineKeyPath(home), { force: true })
150
193
  } catch {}
151
194
  }
152
- const id = crypto.randomUUID()
195
+ // A damaged file, or one made on another computer: replaced once, under the brain lock, so two brains doing it at the
196
+ // same moment keep one id (the second finds the first one's).
153
197
  try {
154
- writePrivateJson(machinePath(home), { id, createdAt: new Date().toISOString(), ...(here ? { hw: here } : {}) })
155
- } catch {}
156
- return id
198
+ return withBrainLockSync(
199
+ home,
200
+ () => {
201
+ const again = readJson<{ id?: unknown; hw?: unknown }>(machinePath(home))
202
+ if (typeof again.id === "string" && UUID.test(again.id) && again.id !== m.id && (!here || again.hw === here)) return again.id
203
+ const id = crypto.randomUUID()
204
+ writePrivateJson(machinePath(home), fresh(id))
205
+ return id
206
+ },
207
+ { what: "this computer's id" },
208
+ )
209
+ } catch {
210
+ const id = crypto.randomUUID()
211
+ try {
212
+ writePrivateJson(machinePath(home), fresh(id))
213
+ } catch {}
214
+ return id
215
+ }
157
216
  }
@@ -145,6 +145,8 @@ export interface FileEdit {
145
145
  holdsKey?: boolean
146
146
  /** Keys among `values` whose value contains the key: never shown or recorded, and replaced when the person signs in again. */
147
147
  secretKeys?: string[]
148
+ /** The mode the file is written with (a launcher script brain writes whole: 0700); otherwise the file keeps its own. */
149
+ mode?: number
148
150
  }
149
151
 
150
152
  export interface Connector {
@@ -161,6 +163,12 @@ export interface Connector {
161
163
  authHeader: "x-api-key" | "authorization" | "x-goog-api-key"
162
164
  /** What the person should know (shown on connect). */
163
165
  notes: string[]
166
+ /** What the person should know at connect that depends on this computer (shown after `notes`). */
167
+ notesHere?(home: string, env: Env): string[]
168
+ /** Files the tool's own launcher writes while it runs (not edits): removed on disconnect. */
169
+ leftovers?(home: string): string[]
170
+ /** What keeps using the gateway after a disconnect or a sign-out until the person quits it (said then). */
171
+ stillRunning?: string
164
172
  }
165
173
 
166
174
  // ---- JSON paths --------------------------------------------------------------------------------------------------------
@@ -33,6 +33,7 @@ import {
33
33
  type GatewaySettings,
34
34
  getAt,
35
35
  type Json,
36
+ modelId,
36
37
  keyPath,
37
38
  MODEL_NAMES,
38
39
  modelName,
@@ -284,6 +285,7 @@ function logoutLocked(home: string, env: Record<string, string | undefined>): st
284
285
  out.push(`${names(readers)} ${readers.length === 1 ? "reads" : "read"} the key from that file, so ${readers.length === 1 ? "it" : "they"} cannot reach the gateway until you sign in again (brain gateway login), and a tool that reads it at start may refuse to start; brain gateway disconnect --tool <id> puts ${readers.length === 1 ? "it" : "them"} back as before.`)
285
286
  if (copies.length)
286
287
  out.push(`${names(copies)} ${copies.length === 1 ? "keeps its" : "keep their"} own copy of the key and still ${copies.length === 1 ? "reaches" : "reach"} the gateway with it (signing out here does not revoke a key): brain gateway disconnect --tool <id> removes the copy.`)
288
+ for (const c of connected) if (c.stillRunning) out.push(c.stillRunning)
287
289
  return out
288
290
  }
289
291
 
@@ -395,7 +397,12 @@ export const CONNECTORS: Connector[] = [...BUILT_IN, ...EXTRA_CONNECTORS]
395
397
  const SECRET_KEYS = new Set(["GEMINI_API_KEY", "model_providers/brain-gateway"])
396
398
  const isSecret = (e: FileEdit, key: string) => SECRET_KEYS.has(key) || !!e.secretKeys?.includes(key)
397
399
 
398
- export const connectorFor = (id: string) => CONNECTORS.find((c) => c.id === id || c.label.toLowerCase() === id.toLowerCase())
400
+ /** Tools that have no connection of their own: they use another tool's (the Codex app reads Codex's ~/.codex). */
401
+ const SAME_CONNECTION: Record<string, string> = { "codex-desktop": "codex", "codex desktop": "codex" }
402
+ export const connectorFor = (id: string) => {
403
+ const want = SAME_CONNECTION[id.toLowerCase()] ?? id
404
+ return CONNECTORS.find((c) => c.id === want || c.label.toLowerCase() === want.toLowerCase())
405
+ }
399
406
 
400
407
  // ---- reading and writing each format ---------------------------------------------------------------------------------
401
408
 
@@ -691,6 +698,12 @@ export async function connect(
691
698
  usePrefs?: boolean
692
699
  /** Called after the files are written, still under the brain lock, with the settings used (to record them). */
693
700
  onWritten?: (s: GatewaySettings) => void
701
+ /**
702
+ * A reconnect for one setting (model, context editing): the values that setting decides are set again (a hand edit of
703
+ * them is replaced, as asking for the value again means); any other value the person changed since brain set it stays
704
+ * theirs (Claude Desktop's applied configuration they chose again in the app, say). A connect asked for takes all back.
705
+ */
706
+ keepTheirs?: { for: "model" | "contextEdit" }
694
707
  } = {},
695
708
  ): Promise<Result> {
696
709
  const env = o.env ?? process.env
@@ -738,6 +751,17 @@ export async function connect(
738
751
  const s = fresh
739
752
  // Read and check every file first: nothing is written unless all of them can be changed.
740
753
  const edits = c.edits(home, s, env)
754
+ // The values the setting being applied decides: those that differ when it is set otherwise.
755
+ const decided = new Set<string>()
756
+ if (o.keepTheirs) {
757
+ const other = o.keepTheirs.for === "model" ? { ...s, model: s.model === modelId("pro") ? modelId("flash") : modelId("pro") } : { ...s, contextEdit: !s.contextEdit }
758
+ const alt = c.edits(home, other, env)
759
+ for (const e of edits)
760
+ for (const k of Object.keys(e.values)) {
761
+ const v = alt.find((x) => x.path === e.path)?.values[k]
762
+ if (JSON.stringify(v) !== JSON.stringify(e.values[k])) decided.add(`${e.path}\u0000${k}`)
763
+ }
764
+ }
741
765
  const docs: Array<{ e: FileEdit; d: Doc }> = []
742
766
  for (const e of edits) {
743
767
  const d = load(e.format, e.path, e.codec)
@@ -754,6 +778,10 @@ export async function connect(
754
778
  // A reconnect keeps the value from before the FIRST connect, so disconnect still restores the person's own.
755
779
  const was = prior.find((x) => x.file === e.path && x.key === key)
756
780
  const secret = isSecret(e, key)
781
+ if (o.keepTheirs && !decided.has(`${e.path}\u0000${key}`) && was && was.after !== "(contains the key)" && !secret && !same(current, was.after)) {
782
+ changes.push(was)
783
+ continue
784
+ }
757
785
  changes.push({ file: e.path, format: e.format, key, before: was ? was.before : current, after: secret ? "(contains the key)" : after })
758
786
  if (!same(current, after)) setKey(d, key, after)
759
787
  shown.push(` ${basename(e.path)}: ${key.replace(/\//g, ".")} = ${secret ? "(gateway settings and key)" : shownValue(after)}`)
@@ -785,7 +813,7 @@ export async function connect(
785
813
  } catch {}
786
814
  }
787
815
  const first = !st.tools[c.id]
788
- for (const { e, d } of docs) writeToolFile(home, c.id, e.path, render(d), e.holdsKey ? 0o600 : undefined, first)
816
+ for (const { e, d } of docs) writeToolFile(home, c.id, e.path, render(d), e.holdsKey ? 0o600 : e.mode, first)
789
817
  st.settings = s
790
818
  st.tools[c.id] = { at: new Date().toISOString(), changes, ...(created.size ? { created: [...created] } : {}), ...(createdDirs.size ? { createdDirs: [...createdDirs] } : {}), ...(Object.keys(modes).length ? { modes } : {}), settings: s }
791
819
  saveState(home, st)
@@ -801,6 +829,7 @@ export async function connect(
801
829
  "changed:",
802
830
  ...shown,
803
831
  ...c.notes.map((n) => `note: ${n}`),
832
+ ...(c.notesHere?.(home, env) ?? []).map((n) => `note: ${n}`),
804
833
  `undo: brain gateway disconnect --tool ${c.id}`,
805
834
  ],
806
835
  }
@@ -855,10 +884,11 @@ function disconnectLocked(home: string, c: Connector, o: { dryRun?: boolean; env
855
884
  } catch {}
856
885
  }
857
886
  if (!o.dryRun) {
887
+ for (const f of c.leftovers?.(home) ?? []) rmSync(f, { force: true })
858
888
  delete st.tools[c.id]
859
889
  saveState(home, st)
860
890
  }
861
- return { ok: true, lines: [`${o.dryRun ? "would disconnect" : "disconnected"} ${c.label}:`, ...lines] }
891
+ return { ok: true, lines: [`${o.dryRun ? "would disconnect" : "disconnected"} ${c.label}:`, ...lines, ...(c.stillRunning ? [`note: ${c.stillRunning}`] : [])] }
862
892
  }
863
893
 
864
894
  export function status(home: string, env: Record<string, string | undefined> = process.env): string[] {