@100xprompt/brain 0.1.5 → 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 +27 -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 +70 -70
- package/src/dashboard/cli.ts +17 -2
- package/src/dashboard/report.ts +89 -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/memory/mcp-install.ts +9 -0
- package/src/memory/sync.ts +25 -3
- package/src/moves.ts +402 -0
- package/src/service.ts +19 -2
- package/src/session/open.ts +82 -0
- package/src/session/ops.ts +111 -0
- package/src/settings/catalog.ts +20 -2
- package/src/settings/cli.ts +8 -0
- package/src/settings/values.ts +28 -0
|
@@ -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
|
+
}
|
package/src/dashboard/state.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
}
|
package/src/gateway/common.ts
CHANGED
|
@@ -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 --------------------------------------------------------------------------------------------------------
|
package/src/gateway/connect.ts
CHANGED
|
@@ -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
|
-
|
|
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 :
|
|
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[] {
|
|
@@ -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] : []
|