@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.
@@ -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[] {
@@ -1,6 +1,206 @@
1
- // Gateway connector for Antigravity: none. The IDE's agent runs in Google's `agy` language server against Google's backend
2
- // with a fixed model list; neither Antigravity IDE 1.107.0 (extensions/agy/package.json settings) nor its docs offer an
3
- // endpoint or key, and a settings.json "openai-compatible" attempt went unanswered (https://discuss.ai.google.dev/t/168654, 2026-06-01).
4
- import type { Connector } from "../common.ts"
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 connectors: Connector[] = []
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
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] : []