@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
|
@@ -1,6 +1,206 @@
|
|
|
1
|
-
// Gateway connector for Antigravity:
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
|
|
1
|
+
// Gateway connector for Antigravity (macOS): a launcher app, "Antigravity with 100xprompt", that opens Antigravity with its
|
|
2
|
+
// agent on the gateway.
|
|
3
|
+
//
|
|
4
|
+
// Antigravity's agent is Google's `agy` (~/.gemini/bin/agy; the IDE starts it as `agy --hub`, passing its own environment).
|
|
5
|
+
// agy 1.5 has an LLM gateway mode that only environment variables turn on (checked 2026-11-11 against agy 1.5.5 by running
|
|
6
|
+
// it against a local stand-in for the gateway, with every other connection refused):
|
|
7
|
+
// AGY_LLM_GATEWAY_URL the gateway's base: agy lists models with GET <base>/v1beta/models and calls
|
|
8
|
+
// POST <base>/v1beta/models/<id>:streamGenerateContent?alt=sse (the Gemini API's shape,
|
|
9
|
+
// wire protocol "genai", the default; "openai" is the other)
|
|
10
|
+
// AGY_LLM_GATEWAY_API_KEY sent as `Authorization: Bearer <key>`; no Google sign-in is needed in this mode
|
|
11
|
+
// AGY_LLM_GATEWAY_TIER_PRO / _FLASH / _FLASH_LITE the model ids agy uses for its own tiers (titles, summaries)
|
|
12
|
+
// (agy sizes a request from the model list: an id it does not know gets 4,096 tokens out and no thinking unless the
|
|
13
|
+
// gateway's list says more, which is why the gateway answers the list itself, on /flash and on /auto)
|
|
14
|
+
// AGY_LLM_GATEWAY_HEADERS extra headers, a JSON object
|
|
15
|
+
// Without them agy uses Google's backend as usual. settings.json has no place for the key (an "env" block there is not
|
|
16
|
+
// read), and the IDE has no setting that reaches agy's environment.
|
|
17
|
+
//
|
|
18
|
+
// So the variables go only to Antigravity, at launch: the launcher runs `open -a "Antigravity IDE" --env ...`, reading the
|
|
19
|
+
// key from brain's key file each time (it is never written into the launcher), and remembers which Antigravity it started.
|
|
20
|
+
// An Antigravity that was already open without them keeps Google's models: the launcher says so instead of pretending.
|
|
21
|
+
// Opening Antigravity the usual way (Dock, Spotlight) is unchanged. A shell-profile export was not used: it would hand the
|
|
22
|
+
// key to every program started from that shell (the same reason as zed.ts).
|
|
23
|
+
import { createHash } from "node:crypto"
|
|
24
|
+
import { join } from "node:path"
|
|
25
|
+
import { AUTO_MODEL, brainDir, EDIT_HEADER, isAuto, keyPath, routeUrl, type Codec, type Connector } from "../common.ts"
|
|
5
26
|
|
|
6
|
-
export const
|
|
27
|
+
export const LAUNCHER_NAME = "Antigravity with 100xprompt"
|
|
28
|
+
const BUNDLE_ID = "com.100xprompt.brain.antigravity"
|
|
29
|
+
/** The names the Antigravity app has had (the launcher opens the first it finds, in /Applications or ~/Applications). */
|
|
30
|
+
const APP_NAMES = ["Antigravity IDE", "Antigravity"]
|
|
31
|
+
const MARK = "brain-antigravity-launcher"
|
|
32
|
+
/** Bumped whenever launcherText changes, so a connect rewrites launchers made by an older brain. */
|
|
33
|
+
const LAUNCHER_FORMAT = 3
|
|
34
|
+
/** The model ids the gateway serves for 100xprompt Pro and Flash (its Gemini routes take them in the path). */
|
|
35
|
+
const PRO = "gemini-3.8-flash"
|
|
36
|
+
const FLASH = "gemini-3.7-flash"
|
|
37
|
+
|
|
38
|
+
export const launcherApp = (home: string) => join(home, "Applications", `${LAUNCHER_NAME}.app`)
|
|
39
|
+
const launcherScript = (home: string) => join(launcherApp(home), "Contents", "MacOS", "launch")
|
|
40
|
+
const launcherPlist = (home: string) => join(launcherApp(home), "Contents", "Info.plist")
|
|
41
|
+
/** The process id of the Antigravity the launcher started (so a second click only brings it forward). */
|
|
42
|
+
const pidFile = (home: string) => join(brainDir(home), "antigravity-launch.pid")
|
|
43
|
+
|
|
44
|
+
export interface LaunchSettings {
|
|
45
|
+
/** The launcher's own format: a newer brain with a changed launcher rewrites it on the next connect. */
|
|
46
|
+
format: number
|
|
47
|
+
url: string
|
|
48
|
+
keyFile: string
|
|
49
|
+
pidFile: string
|
|
50
|
+
pro: string
|
|
51
|
+
flash: string
|
|
52
|
+
headers?: Record<string, string>
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const sq = (s: string) => `'${s.replace(/'/g, `'\\''`)}'`
|
|
56
|
+
|
|
57
|
+
/** The launcher script for `v`. Its first comment line carries `v` back (codec `get`). No key in it. */
|
|
58
|
+
export function launcherText(v: LaunchSettings): string {
|
|
59
|
+
const env: Array<[string, string]> = [
|
|
60
|
+
["AGY_LLM_GATEWAY_URL", v.url],
|
|
61
|
+
["AGY_LLM_GATEWAY_TIER_PRO", v.pro],
|
|
62
|
+
["AGY_LLM_GATEWAY_TIER_FLASH", v.flash],
|
|
63
|
+
["AGY_LLM_GATEWAY_TIER_FLASH_LITE", v.flash],
|
|
64
|
+
...(v.headers && Object.keys(v.headers).length ? ([["AGY_LLM_GATEWAY_HEADERS", JSON.stringify(v.headers)]] as Array<[string, string]>) : []),
|
|
65
|
+
]
|
|
66
|
+
// What Antigravity is started with (not the key): a running one started with other settings is told to restart.
|
|
67
|
+
const fingerprint = createHash("sha256").update(JSON.stringify(env)).digest("hex").slice(0, 16)
|
|
68
|
+
const alert = (title: string, text: string) => `osascript -e ${sq(`display alert ${JSON.stringify(title)} message ${JSON.stringify(text)}`)} >/dev/null 2>&1`
|
|
69
|
+
const apps = APP_NAMES.flatMap((n) => [`/Applications/${n}.app`, `$HOME/Applications/${n}.app`])
|
|
70
|
+
return [
|
|
71
|
+
"#!/bin/sh",
|
|
72
|
+
`# ${MARK} ${JSON.stringify(v)}`,
|
|
73
|
+
"# Opens Antigravity with its agent on the 100xprompt gateway. Written by `brain gateway connect --tool antigravity`;",
|
|
74
|
+
"# undo with `brain gateway disconnect --tool antigravity`. The key is read from brain's key file each time and given to",
|
|
75
|
+
"# Antigravity in open's environment: never kept here, never on a command line.",
|
|
76
|
+
"umask 077",
|
|
77
|
+
`pidfile=${sq(v.pidFile)}`,
|
|
78
|
+
`fp=${sq(fingerprint)}`,
|
|
79
|
+
"# The Antigravity app here (its name has changed between releases).",
|
|
80
|
+
'app=""',
|
|
81
|
+
`for a in ${apps.map((a) => (a.startsWith("$HOME") ? `"${a}"` : sq(a))).join(" ")}; do`,
|
|
82
|
+
' if [ -d "$a" ]; then app=$a; break; fi',
|
|
83
|
+
"done",
|
|
84
|
+
'if [ -z "$app" ]; then',
|
|
85
|
+
` ${alert("Antigravity is not on this computer", `Install Antigravity, then open ${LAUNCHER_NAME} again.`)}`,
|
|
86
|
+
" exit 1",
|
|
87
|
+
"fi",
|
|
88
|
+
"# This person's own Antigravity only: a process of theirs whose command line starts with the app's binary.",
|
|
89
|
+
"app_pid() {",
|
|
90
|
+
' for p in $(pgrep -U "$(id -u)" -f "/Contents/MacOS/"); do',
|
|
91
|
+
' case "$(ps -o command= -p "$p" 2>/dev/null)" in "$app"/Contents/MacOS/*) echo "$p"; return 0 ;; esac',
|
|
92
|
+
" done",
|
|
93
|
+
"}",
|
|
94
|
+
"pid=$(app_pid)",
|
|
95
|
+
'if [ -n "$pid" ]; then',
|
|
96
|
+
' was_pid=""; was_fp=""',
|
|
97
|
+
' if [ -f "$pidfile" ]; then read -r was_pid was_fp < "$pidfile"; fi',
|
|
98
|
+
' if [ "$pid" = "$was_pid" ]; then',
|
|
99
|
+
` [ "$fp" = "$was_fp" ] || ${alert("Antigravity is on older 100xprompt settings", `Your 100xprompt settings changed after Antigravity started. Quit Antigravity, then open ${LAUNCHER_NAME} again to use them.`)}`,
|
|
100
|
+
' exec open -a "$app"',
|
|
101
|
+
" fi",
|
|
102
|
+
` ${alert("Antigravity is already open", `It uses 100xprompt models only when it starts from ${LAUNCHER_NAME}. Quit Antigravity, then open ${LAUNCHER_NAME} again.`)}`,
|
|
103
|
+
' exec open -a "$app"',
|
|
104
|
+
"fi",
|
|
105
|
+
`key=$(head -n 1 ${sq(v.keyFile)} 2>/dev/null)`,
|
|
106
|
+
'if [ -z "$key" ]; then',
|
|
107
|
+
` ${alert(LAUNCHER_NAME, "brain is not signed in to 100xprompt. Run: brain gateway login, then open this again.")}`,
|
|
108
|
+
" exit 1",
|
|
109
|
+
"fi",
|
|
110
|
+
'out=$(AGY_LLM_GATEWAY_API_KEY="$key" open -a "$app" \\',
|
|
111
|
+
...env.map(([k, val]) => ` --env ${sq(`${k}=${val}`)} \\`),
|
|
112
|
+
' 2>&1); rc=$?',
|
|
113
|
+
'case "$out" in *"already running"*|*"could not be set"*) rc=2 ;; esac',
|
|
114
|
+
'if [ "$rc" -ne 0 ]; then',
|
|
115
|
+
` ${alert("Antigravity did not open on 100xprompt", `macOS could not start Antigravity with 100xprompt's settings (it may already be running). Quit Antigravity, then open ${LAUNCHER_NAME} again.`)}`,
|
|
116
|
+
" exit 1",
|
|
117
|
+
"fi",
|
|
118
|
+
"i=0",
|
|
119
|
+
'while [ $i -lt 20 ]; do',
|
|
120
|
+
' pid=$(app_pid); if [ -n "$pid" ]; then echo "$pid $fp" > "$pidfile"; exit 0; fi',
|
|
121
|
+
" i=$((i + 1)); sleep 0.5",
|
|
122
|
+
"done",
|
|
123
|
+
"",
|
|
124
|
+
].join("\n")
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** The launcher's Info.plist: a background app (no Dock icon of its own) that only starts Antigravity. */
|
|
128
|
+
export function plistText(v: { name: string; id: string }): string {
|
|
129
|
+
const esc = (s: string) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")
|
|
130
|
+
return [
|
|
131
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
132
|
+
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
|
|
133
|
+
`<!-- ${MARK} ${JSON.stringify(v)} -->`,
|
|
134
|
+
'<plist version="1.0">',
|
|
135
|
+
"<dict>",
|
|
136
|
+
" <key>CFBundleExecutable</key><string>launch</string>",
|
|
137
|
+
` <key>CFBundleIdentifier</key><string>${esc(v.id)}</string>`,
|
|
138
|
+
` <key>CFBundleName</key><string>${esc(v.name)}</string>`,
|
|
139
|
+
` <key>CFBundleDisplayName</key><string>${esc(v.name)}</string>`,
|
|
140
|
+
" <key>CFBundlePackageType</key><string>APPL</string>",
|
|
141
|
+
" <key>CFBundleShortVersionString</key><string>1.0</string>",
|
|
142
|
+
" <key>LSUIElement</key><true/>",
|
|
143
|
+
"</dict>",
|
|
144
|
+
"</plist>",
|
|
145
|
+
"",
|
|
146
|
+
].join("\n")
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** A file brain writes whole: one key, whose value is carried in the file's marker line. Anyone else's file is left alone. */
|
|
150
|
+
function wholeFile(key: string, render: (v: any) => string): Codec {
|
|
151
|
+
const marker = new RegExp(`${MARK} (\\{.*\\})`)
|
|
152
|
+
return {
|
|
153
|
+
check(text) {
|
|
154
|
+
return text.trim() && !marker.test(text) ? `it is not brain's ${LAUNCHER_NAME} launcher; left as it is` : undefined
|
|
155
|
+
},
|
|
156
|
+
get(text, k) {
|
|
157
|
+
if (k !== key) return undefined
|
|
158
|
+
const m = marker.exec(text)
|
|
159
|
+
if (!m) return undefined
|
|
160
|
+
try {
|
|
161
|
+
return JSON.parse(m[1]!)
|
|
162
|
+
} catch {
|
|
163
|
+
return undefined
|
|
164
|
+
}
|
|
165
|
+
},
|
|
166
|
+
set(_text, k, value) {
|
|
167
|
+
if (k !== key) throw new Error(`unknown launcher key ${k}`)
|
|
168
|
+
return value === undefined ? "" : render(value)
|
|
169
|
+
},
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const antigravity: Connector = {
|
|
174
|
+
id: "antigravity",
|
|
175
|
+
label: "Antigravity",
|
|
176
|
+
file: (home) => launcherScript(home),
|
|
177
|
+
probe: [".gemini/bin/agy", ".gemini/antigravity"],
|
|
178
|
+
authHeader: "authorization",
|
|
179
|
+
edits: (home, s) => {
|
|
180
|
+
// Antigravity lists the route's models (Pro and Flash; on the auto route, auto alone) and calls the Gemini API's paths
|
|
181
|
+
// there. Its own tiers (titles, summaries) get Pro and Flash, or auto.
|
|
182
|
+
const auto = isAuto(s)
|
|
183
|
+
const launch: LaunchSettings = {
|
|
184
|
+
format: LAUNCHER_FORMAT,
|
|
185
|
+
url: routeUrl(s),
|
|
186
|
+
keyFile: keyPath(home),
|
|
187
|
+
pidFile: pidFile(home),
|
|
188
|
+
pro: auto ? AUTO_MODEL : PRO,
|
|
189
|
+
flash: auto ? AUTO_MODEL : FLASH,
|
|
190
|
+
...(s.contextEdit ? { headers: { [EDIT_HEADER]: "on" } } : {}),
|
|
191
|
+
}
|
|
192
|
+
return [
|
|
193
|
+
{ path: launcherPlist(home), format: "custom", codec: wholeFile("bundle", plistText), values: { bundle: { name: LAUNCHER_NAME, id: BUNDLE_ID } } },
|
|
194
|
+
{ path: launcherScript(home), format: "custom", codec: wholeFile("launch", launcherText), values: { launch }, mode: 0o700 },
|
|
195
|
+
]
|
|
196
|
+
},
|
|
197
|
+
notes: [
|
|
198
|
+
`open Antigravity from "${LAUNCHER_NAME}" (in ~/Applications; Spotlight finds it) to use 100xprompt's models; its model menu then lists them. Opened the usual way, Antigravity keeps Google's models.`,
|
|
199
|
+
"quit Antigravity first if it is open: the launcher can only give 100xprompt to an Antigravity it starts.",
|
|
200
|
+
"the key is read from brain's key file each time the launcher runs and given to Antigravity in its environment; it is not written into the launcher, nor put on a command line.",
|
|
201
|
+
],
|
|
202
|
+
leftovers: (home) => [pidFile(home)],
|
|
203
|
+
stillRunning: "an Antigravity started from the launcher keeps using 100xprompt (with the key it started with) until you quit it.",
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export const connectors: Connector[] = process.platform === "darwin" ? [antigravity] : []
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// Gateway connector for Claude Desktop (macOS): its own third-party mode, "Claude (third-party)", pointed at the gateway.
|
|
2
|
+
//
|
|
3
|
+
// Checked 2026-11-11 in Claude 4.23118.0 (app.asar, read only), and with the app's bundled Claude Code (2.1.317) against
|
|
4
|
+
// the gateway's code:
|
|
5
|
+
// - Signed in with a Claude account the app talks to Anthropic only, and sets ANTHROPIC_BASE_URL for its Claude Code
|
|
6
|
+
// itself (so Claude Code's own settings do not move it).
|
|
7
|
+
// - Its third-party mode takes a gateway ("Configure Third-Party Inference…", or an admin's managed preferences): a
|
|
8
|
+
// config library in ~/Library/Application Support/Claude-3p/configLibrary, `_meta.json` ({appliedId, entries:
|
|
9
|
+
// [{id, name}]}) naming the applied `<id>.json`, whose flat keys are the settings: inferenceProvider "gateway",
|
|
10
|
+
// inferenceGatewayBaseUrl, inferenceCredentialKind "helper-script" + inferenceCredentialHelper (a program that prints
|
|
11
|
+
// the key; the app caches it for inferenceCredentialHelperTtlSec), inferenceGatewayAuthScheme "bearer",
|
|
12
|
+
// modelDiscoveryEnabled, inferenceModels ([{name, labelOverride, supports1m, prefer1m, anthropicFamilyTier}]),
|
|
13
|
+
// inferenceCustomHeaders ({name: value}).
|
|
14
|
+
// - The app gives its Claude Code ANTHROPIC_BASE_URL = the gateway, ANTHROPIC_AUTH_TOKEN = the helper's output and
|
|
15
|
+
// the model's name; Claude Code posts the Messages API to <base>/v1/messages, which the gateway's routes serve.
|
|
16
|
+
// - A model's name must read as an Anthropic model (contain claude, opus, sonnet, haiku or anthropic; never gemini and
|
|
17
|
+
// the like): so `anthropic/100x-pro` and `anthropic/100x-flash` (the gateway serves a name with "pro" as Pro and any
|
|
18
|
+
// other as Flash; the auto route serves any name), shown in the menu by their 100xprompt names (labelOverride). With
|
|
19
|
+
// "[1m]" (supports1m) Claude Code takes the 1M window the models have.
|
|
20
|
+
// - Third-party mode is a separate profile (its own sessions and settings, in Claude-3p). The app starts in it when a
|
|
21
|
+
// third-party configuration is applied and the person has not chosen their Claude account there; signing in with the
|
|
22
|
+
// Claude account again goes back.
|
|
23
|
+
// brain writes its own entry (a fixed id) and applies it; the key never goes into the app's files: the helper reads it
|
|
24
|
+
// from brain's key file each time the app asks.
|
|
25
|
+
import { join } from "node:path"
|
|
26
|
+
import { savedMode } from "../../adapters/claude-desktop/records.ts"
|
|
27
|
+
import { AUTO_MODEL, brainDir, EDIT_HEADER, isAuto, keyPath, routeUrl, type Codec, type Connector } from "../common.ts"
|
|
28
|
+
|
|
29
|
+
/** brain's entry in the app's config library (a UUID, as the app requires). */
|
|
30
|
+
export const ENTRY_ID = "b7a1e0c0-0100-4000-8000-000000000100"
|
|
31
|
+
const ENTRY_NAME = "100xprompt"
|
|
32
|
+
const MARK = "brain-claude-desktop-key"
|
|
33
|
+
|
|
34
|
+
export const thirdPartyDir = (home: string) => join(home, "Library", "Application Support", "Claude-3p")
|
|
35
|
+
const libraryDir = (home: string) => join(thirdPartyDir(home), "configLibrary")
|
|
36
|
+
export const metaPath = (home: string) => join(libraryDir(home), "_meta.json")
|
|
37
|
+
export const entryPath = (home: string) => join(libraryDir(home), `${ENTRY_ID}.json`)
|
|
38
|
+
export const helperPath = (home: string) => join(brainDir(home), "bin", "claude-desktop-key")
|
|
39
|
+
|
|
40
|
+
/** The names the app sends; the gateway serves a name with "pro" as Pro, any other as Flash, and the auto route any. */
|
|
41
|
+
const PRO = { name: "anthropic/100x-pro", labelOverride: "100xprompt Pro", supports1m: true, prefer1m: true, anthropicFamilyTier: "sonnet" }
|
|
42
|
+
const FLASH = { name: "anthropic/100x-flash", labelOverride: "100xprompt Flash", supports1m: true, prefer1m: true, anthropicFamilyTier: "haiku" }
|
|
43
|
+
const AUTO = { name: `anthropic/${AUTO_MODEL}`, labelOverride: "100xprompt Auto", supports1m: true, prefer1m: true, anthropicFamilyTier: "sonnet" }
|
|
44
|
+
|
|
45
|
+
/** The helper the app runs for the key: it prints the first line of brain's key file. No key in it. */
|
|
46
|
+
export function helperText(v: { keyFile: string }): string {
|
|
47
|
+
const sq = (s: string) => `'${s.replace(/'/g, `'\\''`)}'`
|
|
48
|
+
return [
|
|
49
|
+
"#!/bin/sh",
|
|
50
|
+
`# ${MARK} ${JSON.stringify(v)}`,
|
|
51
|
+
"# Prints the 100xprompt gateway key for Claude Desktop's third-party mode (its credential helper). Written by",
|
|
52
|
+
"# `brain gateway connect --tool claude-desktop`; undo with `brain gateway disconnect --tool claude-desktop`.",
|
|
53
|
+
`exec head -n 1 ${sq(v.keyFile)}`,
|
|
54
|
+
"",
|
|
55
|
+
].join("\n")
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The helper script, which brain writes whole; anyone else's file there is left alone. */
|
|
59
|
+
const helperCodec: Codec = {
|
|
60
|
+
check(text) {
|
|
61
|
+
return text.trim() && !text.includes(MARK) ? "it is not brain's key helper; left as it is" : undefined
|
|
62
|
+
},
|
|
63
|
+
get(text, key) {
|
|
64
|
+
if (key !== "helper") return undefined
|
|
65
|
+
const m = new RegExp(`${MARK} (\\{.*\\})`).exec(text)
|
|
66
|
+
try {
|
|
67
|
+
return m ? JSON.parse(m[1]!) : undefined
|
|
68
|
+
} catch {
|
|
69
|
+
return undefined
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
set(_text, key, value) {
|
|
73
|
+
if (key !== "helper") throw new Error(`unknown helper key ${key}`)
|
|
74
|
+
return value === undefined ? "" : helperText(value as { keyFile: string })
|
|
75
|
+
},
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
type Meta = { appliedId: string; entries: Array<{ id: string; name: string; [k: string]: unknown }>; [k: string]: unknown }
|
|
79
|
+
const parseMeta = (text: string): Meta => {
|
|
80
|
+
if (!text.trim()) return { appliedId: "", entries: [] }
|
|
81
|
+
const j = JSON.parse(text)
|
|
82
|
+
return { ...j, appliedId: typeof j.appliedId === "string" ? j.appliedId : "", entries: Array.isArray(j.entries) ? j.entries : [] }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The config library's list (`_meta.json`): brain's entry in it ("entry") and which entry is applied ("appliedId"). The
|
|
87
|
+
* person's own entries and anything else the app keeps there are left as they are. A list left with nothing in it is
|
|
88
|
+
* written empty, so a disconnect takes away a file only connect made.
|
|
89
|
+
*/
|
|
90
|
+
const metaCodec: Codec = {
|
|
91
|
+
check(text) {
|
|
92
|
+
if (!text.trim()) return undefined
|
|
93
|
+
try {
|
|
94
|
+
const j = JSON.parse(text)
|
|
95
|
+
if (!j || typeof j !== "object" || Array.isArray(j) || (j.entries !== undefined && !Array.isArray(j.entries))) return "it is not the app's config list; left as it is"
|
|
96
|
+
} catch {
|
|
97
|
+
return "it is not valid JSON; left as it is"
|
|
98
|
+
}
|
|
99
|
+
return undefined
|
|
100
|
+
},
|
|
101
|
+
get(text, key) {
|
|
102
|
+
const m = parseMeta(text)
|
|
103
|
+
// brain's entry by its id alone: renamed in the app, it is still brain's (and goes with brain's file at disconnect).
|
|
104
|
+
if (key === "entry") return m.entries.some((e) => e?.id === ENTRY_ID) ? { id: ENTRY_ID, name: ENTRY_NAME } : undefined
|
|
105
|
+
if (key === "appliedId") return text.trim() ? m.appliedId : undefined
|
|
106
|
+
return undefined
|
|
107
|
+
},
|
|
108
|
+
set(text, key, value) {
|
|
109
|
+
const m = parseMeta(text)
|
|
110
|
+
if (key === "entry") {
|
|
111
|
+
const at = m.entries.findIndex((e) => e?.id === ENTRY_ID)
|
|
112
|
+
if (value === undefined) {
|
|
113
|
+
if (at >= 0) m.entries.splice(at, 1)
|
|
114
|
+
} else if (at >= 0) m.entries[at] = value as Meta["entries"][number]
|
|
115
|
+
else m.entries.push(value as Meta["entries"][number])
|
|
116
|
+
} else if (key === "appliedId") {
|
|
117
|
+
// Never an id the list no longer has (the person deleted that configuration in the app meanwhile).
|
|
118
|
+
const id = typeof value === "string" ? value : ""
|
|
119
|
+
m.appliedId = id && m.entries.some((e) => e?.id === id) ? id : ""
|
|
120
|
+
}
|
|
121
|
+
else throw new Error(`unknown config list key ${key}`)
|
|
122
|
+
const rest = Object.keys(m).filter((k) => k !== "appliedId" && k !== "entries")
|
|
123
|
+
if (!m.entries.length && !m.appliedId && !rest.length) return ""
|
|
124
|
+
return `${JSON.stringify(m, null, 2)}\n`
|
|
125
|
+
},
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const claudeDesktop: Connector = {
|
|
129
|
+
id: "claude-desktop",
|
|
130
|
+
label: "Claude Desktop",
|
|
131
|
+
file: (home) => entryPath(home),
|
|
132
|
+
probe: ["Library/Application Support/Claude"],
|
|
133
|
+
authHeader: "authorization",
|
|
134
|
+
edits: (home, s) => {
|
|
135
|
+
const auto = isAuto(s)
|
|
136
|
+
// The model the person chose comes first: the app starts a session on the first in the list.
|
|
137
|
+
const models = auto ? [AUTO] : s.model === "gemini-3.7-flash" ? [FLASH, PRO] : [PRO, FLASH]
|
|
138
|
+
return [
|
|
139
|
+
{ path: helperPath(home), format: "custom", codec: helperCodec, values: { helper: { keyFile: keyPath(home) } }, mode: 0o700 },
|
|
140
|
+
{
|
|
141
|
+
path: entryPath(home),
|
|
142
|
+
format: "json",
|
|
143
|
+
values: {
|
|
144
|
+
inferenceProvider: "gateway",
|
|
145
|
+
inferenceGatewayBaseUrl: routeUrl(s),
|
|
146
|
+
inferenceGatewayAuthScheme: "bearer",
|
|
147
|
+
inferenceCredentialKind: "helper-script",
|
|
148
|
+
inferenceCredentialHelper: helperPath(home),
|
|
149
|
+
inferenceCredentialHelperTtlSec: 3600,
|
|
150
|
+
modelDiscoveryEnabled: false,
|
|
151
|
+
inferenceModels: models,
|
|
152
|
+
...(s.contextEdit ? { inferenceCustomHeaders: { [EDIT_HEADER]: "on" } } : {}),
|
|
153
|
+
},
|
|
154
|
+
},
|
|
155
|
+
{ path: metaPath(home), format: "custom", codec: metaCodec, values: { entry: { id: ENTRY_ID, name: ENTRY_NAME }, appliedId: ENTRY_ID } },
|
|
156
|
+
]
|
|
157
|
+
},
|
|
158
|
+
notesHere: (home) =>
|
|
159
|
+
savedMode(home) === "1p"
|
|
160
|
+
? ["Claude Desktop is set to start on your Claude account (you chose it in the app), so it keeps doing that: to use 100xprompt, choose Configure Third-Party Inference… in Claude's menu and switch to it there."]
|
|
161
|
+
: [],
|
|
162
|
+
notes: [
|
|
163
|
+
"this sets up Claude Desktop's third-party mode, \"Claude (third-party)\": a separate profile with its own sessions, on 100xprompt's models (shown as 100xprompt Pro and Flash, or Auto). Your Claude account's sessions stay where they are.",
|
|
164
|
+
"quit Claude and open it again: it starts in third-party mode. To go back to your Claude account, sign in with it again in the app; `brain gateway disconnect --tool claude-desktop` takes the setup away.",
|
|
165
|
+
"the key is read from brain's key file each time the app asks (its credential helper); it is not written into the app's files.",
|
|
166
|
+
],
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export const connectors: Connector[] = process.platform === "darwin" ? [claudeDesktop] : []
|
|
@@ -31,6 +31,7 @@ import { connectors as c_cursor_cli } from "./cursor-cli.ts"
|
|
|
31
31
|
import { connectors as c_copilot_cli } from "./copilot-cli.ts"
|
|
32
32
|
import { connectors as c_windsurf } from "./windsurf.ts"
|
|
33
33
|
import { connectors as c_antigravity } from "./antigravity.ts"
|
|
34
|
+
import { connectors as c_claude_desktop } from "./claude-desktop.ts"
|
|
34
35
|
import { connectors as c_amazon_q_cli } from "./amazon-q-cli.ts"
|
|
35
36
|
import { connectors as c_amazon_q_ide } from "./amazon-q-ide.ts"
|
|
36
37
|
import { connectors as c_kiro_cli } from "./kiro-cli.ts"
|
|
@@ -73,6 +74,7 @@ export const EXTRA_CONNECTORS: Connector[] = [
|
|
|
73
74
|
...c_copilot_cli,
|
|
74
75
|
...c_windsurf,
|
|
75
76
|
...c_antigravity,
|
|
77
|
+
...c_claude_desktop,
|
|
76
78
|
...c_amazon_q_cli,
|
|
77
79
|
...c_amazon_q_ide,
|
|
78
80
|
...c_kiro_cli,
|
package/src/handoff.ts
CHANGED
|
@@ -72,12 +72,28 @@ function sameProject(h: Handoff, cwd: string, gitRemote?: string): boolean {
|
|
|
72
72
|
const claimPath = (home: string, id: string, tool: string) => join(handoffDir(home), `${id}.${tool}.claimed`)
|
|
73
73
|
|
|
74
74
|
/** The newest unexpired handoff for this project that `tool` has not received yet. */
|
|
75
|
-
|
|
75
|
+
/**
|
|
76
|
+
* Desktop apps that run a CLI's session-start hook (the same agent inside): a brief for the app is delivered by that hook.
|
|
77
|
+
* Claude Desktop's Code tab runs Claude Code's hooks; the Codex app runs Codex's.
|
|
78
|
+
*/
|
|
79
|
+
export const HOOK_TOOL: Record<string, string> = { "claude-desktop": "claude-code", "codex-desktop": "codex" }
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Whether the hook running is the desktop app's (its Claude Code says so in CLAUDE_CODE_ENTRYPOINT: "claude-desktop",
|
|
83
|
+
* "claude-desktop-3p"). The Codex app's hook cannot be told from the CLI's, so a brief for the Codex app goes to either.
|
|
84
|
+
*/
|
|
85
|
+
export const fromDesktopApp = (target: string, env: Record<string, string | undefined> = process.env) =>
|
|
86
|
+
target !== "claude-desktop" || /^claude-desktop/.test(env.CLAUDE_CODE_ENTRYPOINT ?? "")
|
|
87
|
+
|
|
88
|
+
export function findHandoff(
|
|
89
|
+
home: string,
|
|
90
|
+
q: { cwd: string; tool: string; gitRemote?: string; now?: number; env?: Record<string, string | undefined> },
|
|
91
|
+
): Handoff | undefined {
|
|
76
92
|
const now = q.now ?? Date.now()
|
|
77
93
|
return listHandoffs(home).find(
|
|
78
94
|
(h) =>
|
|
79
95
|
Date.parse(h.expiresAt) > now &&
|
|
80
|
-
(h.target === q.tool || h.target === "any") &&
|
|
96
|
+
(h.target === q.tool || h.target === "any" || (HOOK_TOOL[h.target] === q.tool && fromDesktopApp(h.target, q.env))) &&
|
|
81
97
|
!h.consumedBy.includes(q.tool) &&
|
|
82
98
|
!existsSync(claimPath(home, h.id, q.tool)) &&
|
|
83
99
|
sameProject(h, q.cwd, q.gitRemote),
|
package/src/hooks.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { copyFileSync, existsSync, mkdirSync, readFileSync, realpathSync, statSy
|
|
|
3
3
|
import { dirname, join } from "node:path"
|
|
4
4
|
import { toolLabel } from "./brief.ts"
|
|
5
5
|
import { gitInfo, isDir, writeAtomic } from "./fsutil.ts"
|
|
6
|
-
import { findHandoff, type Handoff, markConsumed } from "./handoff.ts"
|
|
6
|
+
import { findHandoff, type Handoff, HOOK_TOOL, markConsumed } from "./handoff.ts"
|
|
7
7
|
import { syncInBackground } from "./memory/sync.ts"
|
|
8
8
|
import { withBrainLock } from "./lock.ts"
|
|
9
9
|
|
|
@@ -150,7 +150,7 @@ function tomlHasHooks(toml: string): boolean {
|
|
|
150
150
|
}
|
|
151
151
|
|
|
152
152
|
/** "cursor" is what `continue --in` calls Cursor; its handoffs are recorded for "cursor-ide". */
|
|
153
|
-
const toolId = (tool: string) => (tool === "cursor" ? "cursor-ide" : tool)
|
|
153
|
+
const toolId = (tool: string) => (tool === "cursor" ? "cursor-ide" : HOOK_TOOL[tool] ?? tool)
|
|
154
154
|
|
|
155
155
|
const sh = (s: string) => (/^[\w@%+=:,./-]+$/.test(s) ? s : `'${s.replace(/'/g, `'\\''`)}'`)
|
|
156
156
|
|
package/src/lock.ts
CHANGED
|
@@ -93,6 +93,9 @@ export function processStart(pid: number, now = Date.now()): number | undefined
|
|
|
93
93
|
let started: number | undefined
|
|
94
94
|
const myStart = () => (started ??= processStart(process.pid) ?? Math.round(performance.timeOrigin))
|
|
95
95
|
|
|
96
|
+
/** This process's start, as holderGone() judges this process (what a loop records to be judged alive by itself too). */
|
|
97
|
+
export const ownStart = () => myStart()
|
|
98
|
+
|
|
96
99
|
function readOwner(home: string): LockOwner | undefined {
|
|
97
100
|
try {
|
|
98
101
|
const o = JSON.parse(readFileSync(ownerPath(home), "utf8"))
|
|
@@ -31,6 +31,11 @@ export interface McpTool {
|
|
|
31
31
|
|
|
32
32
|
const plain = (command: string, args: string[]): Json => ({ command, args })
|
|
33
33
|
|
|
34
|
+
/** The Claude app's folder, from home (its own MCP settings, claude_desktop_config.json, are there). */
|
|
35
|
+
function claudeDesktopDir(): string {
|
|
36
|
+
return process.platform === "darwin" ? "Library/Application Support/Claude" : process.platform === "win32" ? "AppData/Roaming/Claude" : ".config/Claude"
|
|
37
|
+
}
|
|
38
|
+
|
|
34
39
|
function vscodeUser(): string {
|
|
35
40
|
return process.platform === "darwin" ? "Library/Application Support/Code/User" : process.platform === "win32" ? "AppData/Roaming/Code/User" : ".config/Code/User"
|
|
36
41
|
}
|
|
@@ -38,7 +43,11 @@ function vscodeUser(): string {
|
|
|
38
43
|
export const MCP_TOOLS: McpTool[] = [
|
|
39
44
|
{ id: "claude-code", label: "Claude Code", file: ".claude.json", probe: [".claude.json", ".claude"], format: "json", key: "mcpServers", entry: plain },
|
|
40
45
|
{ id: "codex", label: "Codex", file: ".codex/config.toml", probe: [".codex"], format: "toml" },
|
|
46
|
+
// Claude Desktop: its chats and its Code tab's local tools.
|
|
47
|
+
{ id: "claude-desktop", label: "Claude Desktop", file: `${claudeDesktopDir()}/claude_desktop_config.json`, probe: [`${claudeDesktopDir()}/claude_desktop_config.json`, claudeDesktopDir()], format: "json", key: "mcpServers", entry: plain },
|
|
41
48
|
{ id: "cursor", label: "Cursor", file: ".cursor/mcp.json", probe: [".cursor"], format: "json", key: "mcpServers", entry: plain },
|
|
49
|
+
// Antigravity (Google): its agent's MCP servers.
|
|
50
|
+
{ id: "antigravity", label: "Antigravity", file: ".gemini/antigravity/mcp_config.json", probe: [".gemini/antigravity/mcp_config.json", ".gemini/antigravity"], format: "json", key: "mcpServers", entry: plain },
|
|
42
51
|
{ id: "gemini", label: "Gemini CLI", file: ".gemini/settings.json", probe: [".gemini"], format: "json", key: "mcpServers", entry: plain },
|
|
43
52
|
{
|
|
44
53
|
id: "opencode",
|
package/src/memory/sync.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs"
|
|
6
6
|
import { join } from "node:path"
|
|
7
7
|
import { gitInfo } from "../fsutil.ts"
|
|
8
|
+
import type { SessionRef } from "../adapters/types.ts"
|
|
8
9
|
import type { Session } from "../session/types.ts"
|
|
9
10
|
import { distill } from "./distill.ts"
|
|
10
11
|
import { projectKey } from "./project.ts"
|
|
@@ -17,6 +18,8 @@ export interface SessionSource {
|
|
|
17
18
|
id: string
|
|
18
19
|
cwd?: string
|
|
19
20
|
updatedAt?: string
|
|
21
|
+
/** Ids the same session was listed under before (a desktop app's session that brain once listed as its CLI's). */
|
|
22
|
+
formerIds?: string[]
|
|
20
23
|
read(): Promise<Session>
|
|
21
24
|
}
|
|
22
25
|
|
|
@@ -104,7 +107,20 @@ async function defaultSessions(home: string): Promise<SessionSource[]> {
|
|
|
104
107
|
const lists = await Promise.all(READERS.map((r) => r.list(home).then((refs) => refs.map((ref) => ({ r, ref }))).catch(() => [])))
|
|
105
108
|
return lists
|
|
106
109
|
.flat()
|
|
107
|
-
.map(({ r, ref }) =>
|
|
110
|
+
.map(({ r, ref }) => {
|
|
111
|
+
const former = formerIds(ref)
|
|
112
|
+
return { id: `${ref.tool}:${ref.sourceId}`, ...(ref.cwd ? { cwd: ref.cwd } : {}), ...(ref.updatedAt ? { updatedAt: ref.updatedAt } : {}), ...(former.length ? { formerIds: former } : {}), read: () => r.read(ref, home) }
|
|
113
|
+
})
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** The ids a session was synced under before brain told the desktop apps apart from their CLIs. */
|
|
117
|
+
function formerIds(ref: SessionRef): string[] {
|
|
118
|
+
if (ref.tool === "claude-desktop") {
|
|
119
|
+
const ids = [ref.extra?.cliSessionId, ...(Array.isArray(ref.extra?.priorCliSessionIds) ? ref.extra.priorCliSessionIds : [])]
|
|
120
|
+
return ids.filter((x): x is string => typeof x === "string").map((x) => `claude-code:${x}`)
|
|
121
|
+
}
|
|
122
|
+
if (ref.tool === "codex-desktop") return [`codex:${ref.sourceId}`]
|
|
123
|
+
return []
|
|
108
124
|
}
|
|
109
125
|
|
|
110
126
|
export async function syncMemory(o: SyncOptions, deps: { memory?: Memory; sessions?: () => Promise<SessionSource[]>; now?: () => number } = {}): Promise<SyncResult> {
|
|
@@ -135,9 +151,15 @@ export async function syncMemory(o: SyncOptions, deps: { memory?: Memory; sessio
|
|
|
135
151
|
try {
|
|
136
152
|
const session = await s.read()
|
|
137
153
|
const items = distill(session)
|
|
138
|
-
|
|
154
|
+
const former = (s.formerIds ?? []).filter((id) => ledger[id])
|
|
155
|
+
if (items.length || former.length) {
|
|
139
156
|
const key = projectKey({ ...session.header.project, ...(session.header.project.cwd ? gitInfo(session.header.project.cwd) : {}) })
|
|
140
|
-
|
|
157
|
+
// What was learned from this session under its old id goes, so it is not kept twice (item ids hash the session id).
|
|
158
|
+
for (const id of former) {
|
|
159
|
+
await memory.forget(key, { source: id })
|
|
160
|
+
delete ledger[id]
|
|
161
|
+
}
|
|
162
|
+
if (items.length) await memory.ingest(key, items)
|
|
141
163
|
}
|
|
142
164
|
ledger[s.id] = { u: s.updatedAt ?? "none", n: items.length }
|
|
143
165
|
result.ingested++
|