@100xprompt/brain 0.1.4 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/README.md +41 -1
  2. package/package.json +1 -1
  3. package/src/adapters/amazon-q-ide/writer.ts +3 -1
  4. package/src/adapters/antigravity/reader.ts +25 -7
  5. package/src/adapters/claude-code/reader.ts +4 -0
  6. package/src/adapters/claude-desktop/index.ts +8 -0
  7. package/src/adapters/claude-desktop/reader.ts +140 -0
  8. package/src/adapters/claude-desktop/records.ts +191 -0
  9. package/src/adapters/claude-desktop/writer.ts +112 -0
  10. package/src/adapters/codex/reader.ts +19 -5
  11. package/src/adapters/codex/records.ts +17 -0
  12. package/src/adapters/codex/writer.ts +20 -7
  13. package/src/adapters/codex-desktop/index.ts +38 -0
  14. package/src/adapters/index.ts +4 -0
  15. package/src/cli.ts +79 -71
  16. package/src/dashboard/cli.ts +17 -2
  17. package/src/dashboard/report.ts +98 -5
  18. package/src/dashboard/sessions.ts +127 -0
  19. package/src/dashboard/state.ts +64 -5
  20. package/src/gateway/common.ts +8 -0
  21. package/src/gateway/connect.ts +33 -3
  22. package/src/gateway/connectors/antigravity.ts +205 -5
  23. package/src/gateway/connectors/claude-desktop.ts +169 -0
  24. package/src/gateway/connectors/index.ts +2 -0
  25. package/src/handoff.ts +18 -2
  26. package/src/hooks.ts +2 -2
  27. package/src/lock.ts +3 -0
  28. package/src/memory/mcp-install.ts +9 -0
  29. package/src/memory/sync.ts +25 -3
  30. package/src/moves.ts +402 -0
  31. package/src/service.ts +490 -0
  32. package/src/session/open.ts +82 -0
  33. package/src/session/ops.ts +111 -0
  34. package/src/settings/account.ts +65 -10
  35. package/src/settings/catalog.ts +20 -2
  36. package/src/settings/cli.ts +18 -3
  37. package/src/settings/env.ts +2 -0
  38. package/src/settings/pair.ts +3 -0
  39. package/src/settings/values.ts +28 -0
package/src/service.ts ADDED
@@ -0,0 +1,490 @@
1
+ // `brain service install | uninstall | status | run`: a brain that runs in the background, so a settings change made in the
2
+ // dashboard reaches this computer by itself (docs/settings-contract.md, section 6).
3
+ //
4
+ // install macOS: a LaunchAgent (~/Library/LaunchAgents/com.100xprompt.brain.plist, `launchctl bootstrap gui/<uid>`);
5
+ // Linux: a systemd user unit (~/.config/systemd/user/brain.service, `systemctl --user enable --now`). Both start
6
+ // ~/.brain/bin/brain, a small script named brain (so the system's notice names brain, not bun) that runs the
7
+ // bun on the person's PATH with this brain's cli.ts. Never from a temporary bunx copy.
8
+ // run the loop: every 60 s one exchange with the account (account.ts); when it took or applied anything, the report
9
+ // goes at once. Offline or failing: it waits longer each time (2, 4, 8, at most 10 minutes). One per home: a
10
+ // second waits until the first stops. It exits with RESTART_EXIT when the brain on disk is a newer version
11
+ // (launchd / systemd start the new one: they restart it only after that or a crash), and with 0 when brain was
12
+ // removed from disk (so nothing restarts it).
13
+ // The person's environment comes from ~/.brain/env.json (their tool folders and opt-outs): written into the plist / unit,
14
+ // and read again every round, so a brain started by launchd edits the files the person's tools read.
15
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"
16
+ import { dirname, join, resolve as resolvePath } from "node:path"
17
+ import { writeAtomic } from "./fsutil.ts"
18
+ import { holderGone, ownStart, withBrainLockSync } from "./lock.ts"
19
+ import { CLI_PATH, isEphemeral, stableBun, VERSION } from "./self.ts"
20
+ import { ENV_VARS, readRecordedEnv } from "./settings/env.ts"
21
+
22
+ export const LABEL = "com.100xprompt.brain"
23
+ export const plistPath = (home: string) => join(home, "Library", "LaunchAgents", `${LABEL}.plist`)
24
+ export const unitPath = (home: string) => join(home, ".config", "systemd", "user", "brain.service")
25
+ export const wrapperPath = (home: string) => join(home, ".brain", "bin", "brain")
26
+ export const serviceStatePath = (home: string) => join(home, ".brain", "service.json")
27
+ export const serviceLogPath = (home: string) => join(home, ".brain", "logs", "service.log")
28
+
29
+ type Env = Record<string, string | undefined>
30
+ type Io = { out(l: string): void }
31
+
32
+ export interface ServiceDeps {
33
+ /** Runs launchctl / systemctl (tests replace it). */
34
+ run?: (argv: string[]) => { code: number; out: string }
35
+ platform?: string
36
+ uid?: number
37
+ /** The cli.ts the service starts (default: this brain's). */
38
+ cli?: string
39
+ bun?: string
40
+ }
41
+
42
+ const runCmd = (argv: string[]): { code: number; out: string } => {
43
+ try {
44
+ const p = Bun.spawnSync(argv, { stdout: "pipe", stderr: "pipe", timeout: 15_000 })
45
+ return { code: p.exitCode ?? 1, out: `${p.stdout.toString()}${p.stderr.toString()}` }
46
+ } catch (e) {
47
+ return { code: 1, out: String((e as Error).message) }
48
+ }
49
+ }
50
+
51
+ export interface ServiceState {
52
+ pid?: number
53
+ startedAt?: number
54
+ version?: string
55
+ lastRunAt?: string
56
+ lastOkAt?: string
57
+ lastError?: string
58
+ }
59
+
60
+ /** The exit code that has launchd / systemd start brain again (a newer brain is on disk). 0 leaves it stopped. */
61
+ export const RESTART_EXIT = 75
62
+
63
+ export function readServiceState(home: string): ServiceState {
64
+ try {
65
+ const j = JSON.parse(readFileSync(serviceStatePath(home), "utf8"))
66
+ return j && typeof j === "object" ? j : {}
67
+ } catch {
68
+ return {}
69
+ }
70
+ }
71
+
72
+ function writeServiceState(home: string, s: ServiceState) {
73
+ try {
74
+ mkdirSync(join(home, ".brain"), { recursive: true, mode: 0o700 })
75
+ writeAtomic(serviceStatePath(home), `${JSON.stringify(s, null, 1)}\n`, 0o600)
76
+ } catch {}
77
+ }
78
+
79
+ /** Whether the service is installed here (its plist or unit exists), and whether its loop is alive (cheap: no launchctl). */
80
+ export function serviceState(home: string, platform: string = process.platform, every = loopEvery()): { installed: boolean; running: boolean; lastRunAt: string | null } {
81
+ const installed = existsSync(platform === "darwin" ? plistPath(home) : unitPath(home))
82
+ const s = readServiceState(home)
83
+ const alive = typeof s.pid === "number" && typeof s.startedAt === "number" && !holderGone({ pid: s.pid, startedAt: s.startedAt, at: "" })
84
+ // Its last round, or its start when it has had none yet (the first round can take its full 10 s).
85
+ const last = s.lastRunAt ? Date.parse(s.lastRunAt) : s.startedAt
86
+ const recent = typeof last === "number" && Number.isFinite(last) && Date.now() - last < Math.max(every, 60_000) * 12
87
+ return { installed, running: alive && recent, lastRunAt: s.lastRunAt ?? null }
88
+ }
89
+
90
+ /** The environment the service runs with: the person's recorded values, and where brain keeps its files. */
91
+ function serviceEnv(home: string, env: Env): Record<string, string> {
92
+ const out: Record<string, string> = {}
93
+ for (const [k, v] of Object.entries(readRecordedEnv(home)?.vars ?? {})) if (typeof v === "string" && v) out[k] = v
94
+ // A brain kept somewhere other than the home folder (BRAIN_HOME) keeps it there in the background too.
95
+ if (env.BRAIN_HOME) out.BRAIN_HOME = env.BRAIN_HOME
96
+ if (!out.PATH && env.PATH) out.PATH = env.PATH
97
+ return out
98
+ }
99
+
100
+ const xml = (s: string) => s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&apos;" })[c]!)
101
+ const shq = (s: string) => `'${s.replace(/'/g, `'\\''`)}'`
102
+
103
+ export function plistText(wrapper: string, env: Record<string, string>, log: string): string {
104
+ const envXml = Object.entries(env)
105
+ .map(([k, v]) => ` <key>${xml(k)}</key>\n <string>${xml(v)}</string>`)
106
+ .join("\n")
107
+ return `<?xml version="1.0" encoding="UTF-8"?>
108
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
109
+ <plist version="1.0">
110
+ <dict>
111
+ <key>Label</key>
112
+ <string>${LABEL}</string>
113
+ <key>ProgramArguments</key>
114
+ <array>
115
+ <string>${xml(wrapper)}</string>
116
+ <string>service</string>
117
+ <string>run</string>
118
+ </array>
119
+ <key>RunAtLoad</key>
120
+ <true/>
121
+ <key>KeepAlive</key>
122
+ <dict>
123
+ <key>SuccessfulExit</key>
124
+ <false/>
125
+ </dict>
126
+ <key>ThrottleInterval</key>
127
+ <integer>30</integer>
128
+ <key>ProcessType</key>
129
+ <string>Background</string>
130
+ <key>LowPriorityIO</key>
131
+ <true/>
132
+ <key>EnvironmentVariables</key>
133
+ <dict>
134
+ ${envXml}
135
+ </dict>
136
+ <key>StandardOutPath</key>
137
+ <string>${xml(log)}</string>
138
+ <key>StandardErrorPath</key>
139
+ <string>${xml(log)}</string>
140
+ </dict>
141
+ </plist>
142
+ `
143
+ }
144
+
145
+ /**
146
+ * A value inside systemd's double quotes: \ " and line breaks escaped, % doubled (a specifier otherwise), and in a command
147
+ * line $ doubled too (a variable otherwise).
148
+ */
149
+ const sdq = (v: string, command = false) => {
150
+ let t = v.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/%/g, "%%")
151
+ if (command) t = t.replace(/\$/g, "$$$$")
152
+ return `"${t}"`
153
+ }
154
+
155
+ export function unitText(wrapper: string, env: Record<string, string>, log: string): string {
156
+ const envLines = Object.entries(env)
157
+ .map(([k, v]) => `Environment=${sdq(`${k}=${v}`)}`)
158
+ .join("\n")
159
+ // The log path is taken as it is after "append:" (no quotes there): only % needs doubling, and a line break cannot be kept.
160
+ const logPath = log.replace(/%/g, "%%").replace(/[\r\n]/g, "")
161
+ return `[Unit]
162
+ Description=brain by 100xprompt: keeps this computer's settings in step with your account
163
+
164
+ [Service]
165
+ ExecStart=${sdq(wrapper, true)} service run
166
+ Restart=on-failure
167
+ RestartSec=30
168
+ StandardOutput=append:${logPath}
169
+ StandardError=append:${logPath}
170
+ ${envLines}
171
+
172
+ [Install]
173
+ WantedBy=default.target
174
+ `
175
+ }
176
+
177
+ /** `brain service install`: the wrapper, the plist or unit, and the system told to start it. */
178
+ export function installService(home: string, io: Io, env: Env = process.env, deps: ServiceDeps = {}): number {
179
+ const platform = deps.platform ?? process.platform
180
+ if (platform !== "darwin" && platform !== "linux") {
181
+ io.out("brain service: not on this system yet (macOS and Linux only); run `brain sync` to take changes made in the dashboard")
182
+ return 1
183
+ }
184
+ const cli = deps.cli ?? CLI_PATH
185
+ if (isEphemeral(cli)) {
186
+ io.out("brain service: this brain is a temporary bunx copy, which a reboot or cache clean removes; install brain first: bun add -g @100xprompt/brain")
187
+ return 1
188
+ }
189
+ // launchd and systemd read the real home's folders: a brain kept elsewhere (BRAIN_HOME, used by tests) cannot have one.
190
+ if (env.BRAIN_HOME && env.HOME && resolvePath(env.BRAIN_HOME) !== resolvePath(env.HOME)) {
191
+ io.out("brain service: BRAIN_HOME points away from your home folder, and the system starts background services only from your home's; run it without BRAIN_HOME")
192
+ return 1
193
+ }
194
+ const run = deps.run ?? runCmd
195
+ const bun = deps.bun ?? stableBun()
196
+ const wrapper = wrapperPath(home)
197
+ const vars = serviceEnv(home, env)
198
+ const file = platform === "darwin" ? plistPath(home) : unitPath(home)
199
+ // A script an earlier install left is kept when this one fails: a loaded service still starts it.
200
+ const hadWrapper = existsSync(wrapper)
201
+ try {
202
+ mkdirSync(dirname(wrapper), { recursive: true, mode: 0o700 })
203
+ mkdirSync(dirname(serviceLogPath(home)), { recursive: true, mode: 0o700 })
204
+ // Named brain, so the system's "background item added" notice says brain; it only starts this brain. A brain (or bun)
205
+ // removed from disk ends it quietly: exit 0, which the system does not restart.
206
+ writeAtomic(
207
+ wrapper,
208
+ `#!/bin/sh\n# brain's background service (brain service install). It runs this brain; brain service uninstall removes it.\n[ -f ${shq(cli)} ] && command -v ${shq(bun)} >/dev/null 2>&1 || exit 0\nexec ${shq(bun)} ${shq(cli)} "$@"\n`,
209
+ 0o755,
210
+ )
211
+ mkdirSync(dirname(file), { recursive: true })
212
+ writeFileSync(file, platform === "darwin" ? plistText(wrapper, vars, serviceLogPath(home)) : unitText(wrapper, vars, serviceLogPath(home)), { mode: 0o644 })
213
+ } catch (e) {
214
+ if (!hadWrapper)
215
+ try {
216
+ rmSync(wrapper, { force: true })
217
+ } catch {}
218
+ const err = e as NodeJS.ErrnoException
219
+ const why = err?.code === "EACCES" || err?.code === "EPERM" ? "permission denied" : String(err?.message ?? e).slice(0, 200)
220
+ // The file or folder that failed, as the error names it (the wrapper's folder, the log's, LaunchAgents...).
221
+ const at = typeof err?.path === "string" ? err.path : file
222
+ io.out(`brain service: could not write ${at} (${why}); check that you own ${dirname(at)} (sudo chown $USER ${dirname(at)}), then run brain service install again`)
223
+ return 1
224
+ }
225
+ if (platform === "darwin") {
226
+ const uid = deps.uid ?? process.getuid?.() ?? 501
227
+ // Loaded afresh: an older one (another version, other environment) goes first.
228
+ run(["launchctl", "bootout", `gui/${uid}/${LABEL}`])
229
+ const r = run(["launchctl", "bootstrap", `gui/${uid}`, file])
230
+ if (r.code !== 0) {
231
+ io.out(`brain service: macOS did not start it (launchctl: ${r.out.trim().slice(0, 200) || `exit ${r.code}`}); the file is at ${file}`)
232
+ return 1
233
+ }
234
+ } else {
235
+ run(["systemctl", "--user", "daemon-reload"])
236
+ const r = run(["systemctl", "--user", "enable", "brain.service"])
237
+ // Restarted, not just started: an install again (another brain, other environment) takes effect at once.
238
+ const r2 = r.code === 0 ? run(["systemctl", "--user", "restart", "brain.service"]) : r
239
+ if (r2.code !== 0) {
240
+ io.out(`brain service: systemd did not start it (systemctl: ${r2.out.trim().slice(0, 200) || `exit ${r2.code}`}); the unit is at ${file}`)
241
+ return 1
242
+ }
243
+ }
244
+ io.out("brain service: installed and started. Every minute it takes changes made in your dashboard and applies them here.")
245
+ io.out(` log: ${serviceLogPath(home)}; stop it: brain service uninstall`)
246
+ return 0
247
+ }
248
+
249
+ export function uninstallService(home: string, io: Io, deps: ServiceDeps = {}): number {
250
+ const platform = deps.platform ?? process.platform
251
+ const run = deps.run ?? runCmd
252
+ let had = false
253
+ if (platform === "darwin") {
254
+ const uid = deps.uid ?? process.getuid?.() ?? 501
255
+ if (existsSync(plistPath(home))) {
256
+ had = true
257
+ run(["launchctl", "bootout", `gui/${uid}/${LABEL}`])
258
+ rmSync(plistPath(home), { force: true })
259
+ }
260
+ } else if (platform === "linux" && existsSync(unitPath(home))) {
261
+ had = true
262
+ run(["systemctl", "--user", "disable", "--now", "brain.service"])
263
+ rmSync(unitPath(home), { force: true })
264
+ run(["systemctl", "--user", "daemon-reload"])
265
+ }
266
+ rmSync(wrapperPath(home), { force: true })
267
+ io.out(had ? "brain service: stopped and removed; `brain sync` (or brain status) still takes dashboard changes by hand" : "brain service: not installed here")
268
+ return 0
269
+ }
270
+
271
+ const ago = (iso?: string) => {
272
+ const t = iso ? Date.parse(iso) : NaN
273
+ if (!Number.isFinite(t)) return "never"
274
+ const s = Math.max(0, Math.round((Date.now() - t) / 1000))
275
+ return s < 90 ? `${s}s ago` : s < 5400 ? `${Math.round(s / 60)}m ago` : `${Math.round(s / 3600)}h ago`
276
+ }
277
+
278
+ export function serviceStatusLines(home: string, platform: string = process.platform): string[] {
279
+ if (platform !== "darwin" && platform !== "linux") return ["service: not on this system yet (brain sync takes the dashboard's changes)"]
280
+ const st = serviceState(home, platform)
281
+ const s = readServiceState(home)
282
+ if (!st.installed) return ["service: not installed (brain service install: dashboard changes then apply by themselves)"]
283
+ return [
284
+ `service: installed, ${st.running ? `running (version ${s.version ?? "?"}, last round ${ago(s.lastRunAt)})` : "not running right now"}`,
285
+ ...(s.lastError ? [` the last round failed: ${s.lastError}`] : []),
286
+ ` log: ${serviceLogPath(home)}`,
287
+ ]
288
+ }
289
+
290
+ // ---- the loop -------------------------------------------------------------------------------------------------------------
291
+
292
+ /** BRAIN_SERVICE_EVERY_MS: the time between rounds (tests use a short one). */
293
+ export function loopEvery(env: Env = process.env): number {
294
+ const n = Number(env.BRAIN_SERVICE_EVERY_MS)
295
+ return env.BRAIN_SERVICE_EVERY_MS && Number.isFinite(n) && n >= 10 ? n : 60_000
296
+ }
297
+
298
+ /** The wait after `failures` failed rounds in a row: 1, 2, 4, 8 times the round, at most 10 minutes. */
299
+ export const backoff = (every: number, failures: number) => (failures <= 0 ? every : Math.min(Math.max(every * 2 ** failures, every), Math.max(every, 600_000)))
300
+
301
+ /** The version of the brain on disk now (a `bun add -g` while the service runs replaces it). */
302
+ function versionOnDisk(cli: string): string | undefined {
303
+ try {
304
+ return JSON.parse(readFileSync(join(dirname(cli), "..", "package.json"), "utf8")).version
305
+ } catch {
306
+ return undefined
307
+ }
308
+ }
309
+
310
+ /**
311
+ * The person's recorded environment, applied to this process: what their shell has now (a tool folder or opt-out they
312
+ * dropped is dropped here too, whatever the plist or unit started this process with). PATH stays when none is recorded.
313
+ */
314
+ export function refreshEnv(home: string, env: Env = process.env) {
315
+ const rec = readRecordedEnv(home)
316
+ if (!rec) return
317
+ for (const k of ENV_VARS) {
318
+ const v = rec.vars[k]
319
+ if (typeof v === "string" && v) env[k] = v
320
+ else if (k !== "PATH") delete env[k]
321
+ }
322
+ }
323
+
324
+ export interface LoopOptions {
325
+ /** Stop after this many rounds (tests). */
326
+ rounds?: number
327
+ /** How the person is asked about a move from the dashboard (tests; else a dialog, moves.ts). */
328
+ ask?: import("./moves.ts").Ask
329
+ env?: Env
330
+ fetchImpl?: typeof fetch
331
+ say?: (line: string) => void
332
+ cli?: string
333
+ }
334
+
335
+ type Turn = { ours: true } | { ours: false; pid: number }
336
+
337
+ /**
338
+ * This loop's turn for the home, read, judged and written under the brain lock (two loops starting at once never both
339
+ * run): another live loop keeps it; otherwise `state` is written as this loop's. Called every round, so a loop that lost
340
+ * its turn stops exchanging.
341
+ */
342
+ function keepTurn(home: string, state: ServiceState, every: number): Turn {
343
+ return withBrainLockSync(
344
+ home,
345
+ () => {
346
+ const cur = readServiceState(home)
347
+ const mine = cur.pid === state.pid && cur.startedAt === state.startedAt
348
+ // Another loop has the turn while its process lives and it has run a round lately (as serviceState judges running):
349
+ // a process id reused by something else, where its start cannot be told (no ps), never holds it for good.
350
+ const last = cur.lastRunAt ? Date.parse(cur.lastRunAt) : cur.startedAt
351
+ const lately = typeof last === "number" && Number.isFinite(last) && Date.now() - last < Math.max(every, 60_000) * 12
352
+ if (!mine && lately && typeof cur.pid === "number" && typeof cur.startedAt === "number" && cur.pid !== process.pid && !holderGone({ pid: cur.pid, startedAt: cur.startedAt, at: "" }))
353
+ return { ours: false, pid: cur.pid }
354
+ writeServiceState(home, state)
355
+ return { ours: true }
356
+ },
357
+ { what: "the background service's turn" },
358
+ )
359
+ }
360
+
361
+ /**
362
+ * `brain service run`: the loop. Returns when it should stop: the brain on disk changed ("updated"), brain was removed
363
+ * from disk ("gone"), or (tests) after `rounds` ("another" when another loop had the home all along). A round that fails
364
+ * in any way is a failed round, never the end of the process.
365
+ */
366
+ export async function runService(home: string, o: LoopOptions = {}): Promise<"stopped" | "another" | "updated" | "gone"> {
367
+ const env = o.env ?? process.env
368
+ const say = o.say ?? ((l: string) => process.stdout.write(`${new Date().toISOString()} ${l}\n`))
369
+ const cli = o.cli ?? CLI_PATH
370
+ // Recorded as this process judges itself (lock.ts), so its own reports say it runs.
371
+ const state: ServiceState = { pid: process.pid, startedAt: ownStart(), version: VERSION }
372
+ say(`brain service ${VERSION} started`)
373
+ const { exchange } = await import("./settings/account.ts")
374
+ const { sendingOff, sendReport, canReport } = await import("./dashboard/send.ts")
375
+ let failures = 0
376
+ let waiting: number | undefined
377
+ let everOurs = false
378
+ for (let round = 1; ; round++) {
379
+ let wait = loopEvery(env)
380
+ try {
381
+ refreshEnv(home, env)
382
+ // Gone twice, 2 s apart: `bun add -g` replaces the package with cli.ts missing for a few milliseconds.
383
+ if (!existsSync(cli) && (await Bun.sleep(2_000), !existsSync(cli))) {
384
+ say("brain is no longer on this computer: this service stops (brain service uninstall removes what is left)")
385
+ return "gone"
386
+ }
387
+ const now = versionOnDisk(cli)
388
+ if (now && now !== VERSION) {
389
+ say(`brain on disk is now ${now}: this ${VERSION} stops, and the system starts the new one`)
390
+ if (everOurs) keepTurn(home, { ...state, lastRunAt: new Date().toISOString() }, loopEvery(env))
391
+ return "updated"
392
+ }
393
+ const turn = keepTurn(home, state, loopEvery(env))
394
+ if (!turn.ours) {
395
+ if (waiting !== turn.pid) say(`another brain service runs for this home (process ${turn.pid}); this one waits until it stops`)
396
+ waiting = turn.pid
397
+ if (o.rounds && round >= o.rounds) return everOurs ? "stopped" : "another"
398
+ await Bun.sleep(wait)
399
+ continue
400
+ }
401
+ if (waiting !== undefined) say("the other brain service stopped: this one takes over")
402
+ waiting = undefined
403
+ everOurs = true
404
+ const x = await exchange(home, { env, timeoutMs: 10_000, ...(o.fetchImpl ? { fetchImpl: o.fetchImpl } : {}) })
405
+ const at = new Date().toISOString()
406
+ if (!x.paired) {
407
+ state.lastRunAt = at
408
+ delete state.lastError
409
+ } else if (x.ok) {
410
+ failures = 0
411
+ Object.assign(state, { lastRunAt: at, lastOkAt: at })
412
+ delete state.lastError
413
+ } else {
414
+ failures++
415
+ Object.assign(state, { lastRunAt: at, lastError: x.why ?? "unknown" })
416
+ say(`round failed: ${x.why}`)
417
+ }
418
+ // Written before the report: the report says this loop runs.
419
+ keepTurn(home, state, loopEvery(env))
420
+ let moved = 0
421
+ if (x.ok) {
422
+ // Moves asked from the dashboard (moves.ts, docs/sessions-contract.md): as moves.fromDashboard says (ask first).
423
+ try {
424
+ const { processMoves } = await import("./moves.ts")
425
+ const mv = await processMoves(home, { ...(o.fetchImpl ? { fetchImpl: o.fetchImpl } : {}), ...(o.ask ? { ask: o.ask } : {}), say })
426
+ moved = mv.done + mv.refused + mv.failed + mv.pending
427
+ } catch {}
428
+ // The session list when it changed (looked at no more than every 5 minutes), and at once after a move.
429
+ try {
430
+ const { sendSessions } = await import("./dashboard/sessions.ts")
431
+ const sent = await sendSessions(home, { env, force: moved > 0, ...(o.fetchImpl ? { fetchImpl: o.fetchImpl } : {}) })
432
+ if (sent.sent) say(`sent the session list (${sent.total} sessions)`)
433
+ } catch {}
434
+ }
435
+ if (x.ok && (x.received.length || x.applied.length || moved)) {
436
+ if (x.received.length || x.applied.length) say(`took ${x.received.length} change(s) from your account, applied ${x.applied.length}`)
437
+ // The dashboard sees the result at once.
438
+ if (canReport(home) && !sendingOff(home, env)) {
439
+ try {
440
+ const { buildReport } = await import("./dashboard/report.ts")
441
+ const r = await sendReport(home, await buildReport(home, { env }), o.fetchImpl ? { fetchImpl: o.fetchImpl } : {})
442
+ if (!r.ok) say(`report not sent: ${r.why}`)
443
+ } catch {}
444
+ }
445
+ }
446
+ wait = backoff(loopEvery(env), failures)
447
+ } catch (e) {
448
+ // The brain lock busy for long, a disk that is full, anything else: this round failed, the loop goes on.
449
+ failures++
450
+ state.lastRunAt = new Date().toISOString()
451
+ state.lastError = `the round stopped: ${String((e as Error)?.message ?? e).slice(0, 200)}`
452
+ say(state.lastError)
453
+ try {
454
+ if (everOurs) keepTurn(home, state, loopEvery(env))
455
+ } catch {}
456
+ wait = backoff(loopEvery(env), failures)
457
+ }
458
+ if (o.rounds && round >= o.rounds) return "stopped"
459
+ await Bun.sleep(wait)
460
+ }
461
+ }
462
+
463
+ // ---- the command ---------------------------------------------------------------------------------------------------------
464
+
465
+ const USAGE = [
466
+ "brain service install run brain in the background, so changes made in your dashboard apply here by themselves",
467
+ "brain service uninstall stop it and remove it",
468
+ "brain service status whether it is installed and running, and its last round",
469
+ ]
470
+
471
+ export async function serviceCommand(args: string[], home: string, io: Io = { out: (l) => console.log(l) }): Promise<number> {
472
+ const sub = args[0]
473
+ switch (sub) {
474
+ case "install":
475
+ return installService(home, io)
476
+ case "uninstall":
477
+ return uninstallService(home, io)
478
+ case "status":
479
+ case undefined:
480
+ serviceStatusLines(home).forEach((l) => io.out(l))
481
+ return 0
482
+ case "run":
483
+ // Only a newer brain on disk (or a crash) has the system start it again; brain removed from disk stays stopped.
484
+ return (await runService(home)) === "updated" ? RESTART_EXIT : 0
485
+ default:
486
+ io.out(`unknown service command "${sub}"`)
487
+ USAGE.forEach((l) => io.out(`usage: ${l}`))
488
+ return 2
489
+ }
490
+ }
@@ -0,0 +1,82 @@
1
+ // `brain session open <id>`: a session opened in its own tool, in its own folder: the tool's resume command, as brain's
2
+ // writers print it after a move (src/adapters/*/writer.ts). The dashboard shows `brain session open <id>` for every
3
+ // session, so it needs no path and no tool's own syntax (docs/sessions-contract.md, section 3).
4
+ import { existsSync } from "node:fs"
5
+ import type { SessionRef } from "../adapters/types.ts"
6
+ import { LAUNCHER_NAME, launcherApp } from "../gateway/connectors/antigravity.ts"
7
+
8
+ /** A word for sh: as it is when it holds nothing the shell reads, else in single quotes (as the writers quote). */
9
+ export const shellQuote = (s: string) => (/^[\w./@%+=:,-]+$/.test(s) ? s : `'${s.replace(/'/g, `'\\''`)}'`)
10
+
11
+ type Resume = (id: string, cwd: string) => string
12
+
13
+ /** Terminal tools, by brain's reader id: the command that reopens session `id` (run in the session's folder). */
14
+ const IN_FOLDER: Record<string, Resume> = {
15
+ "claude-code": (id) => `claude --resume ${shellQuote(id)}`,
16
+ codex: (id) => `codex resume ${shellQuote(id)}`,
17
+ "gemini-cli": (id) => `gemini --resume ${shellQuote(id)}`,
18
+ "copilot-cli": (id) => `copilot --resume ${shellQuote(id)}`,
19
+ opencode: (id) => `opencode --session ${shellQuote(id)}`,
20
+ aider: () => "aider --restore-chat-history",
21
+ "amazon-q-cli": () => "q chat --resume",
22
+ codebuff: (id) => `codebuff --continue ${shellQuote(id)}`,
23
+ crush: (id) => `crush --session ${shellQuote(id)}`,
24
+ "factory-droid": (id) => `droid --resume ${shellQuote(id)}`,
25
+ goose: (id) => `goose session --resume --session-id ${shellQuote(id)} --history`,
26
+ "grok-cli": (id) => `grok --resume ${shellQuote(id)}`,
27
+ "iflow-cli": (id) => `iflow --resume ${shellQuote(id)}`,
28
+ "kimi-code": (id) => `kimi --session ${shellQuote(id)}`,
29
+ "mistral-vibe": (id) => `vibe --resume ${shellQuote(id)}`,
30
+ openhands: (id) => `openhands --resume ${shellQuote(id)}`,
31
+ "qoder-cli": (id) => `qodercli --resume ${shellQuote(id)}`,
32
+ "qwen-code": (id) => `qwen --resume ${shellQuote(id)}`,
33
+ "tabnine-cli": (id) => `tabnine --resume ${shellQuote(id)}`,
34
+ antigravity: (id) => `agy --conversation ${shellQuote(id)}`,
35
+ devin: (id) => `devin --resume ${shellQuote(id)}`,
36
+ omp: (id) => `omp --resume ${shellQuote(id)}`,
37
+ }
38
+
39
+ /** Desktop apps: opened on the session's folder, where the person picks the session from the app's history. */
40
+ const APPS: Record<string, (cwd: string) => string | undefined> = {
41
+ // The Claude app opens on its own sessions (the Code tab): no folder to give it.
42
+ "claude-desktop": () => (process.platform === "darwin" ? "open -a Claude" : undefined),
43
+ "codex-desktop": () => (process.platform === "darwin" ? "open -a Codex" : undefined),
44
+ "cursor-ide": (cwd) => (process.platform === "darwin" ? `open -a Cursor ${shellQuote(cwd)}` : `cursor ${shellQuote(cwd)}`),
45
+ "vscode-copilot": (cwd) => `code ${shellQuote(cwd)}`,
46
+ zed: (cwd) => `zed ${shellQuote(cwd)}`,
47
+ void: (cwd) => (process.platform === "darwin" ? `open -a Void ${shellQuote(cwd)}` : undefined),
48
+ }
49
+
50
+ export type OpenPlan = { command: string; note?: string } | { none: string }
51
+
52
+ /** How session `ref` is opened again where it is, or why brain cannot do that for its tool. */
53
+ /** Antigravity's IDE app on this computer, when there is one (macOS: its name has changed between releases). */
54
+ function antigravityApp(): string | undefined {
55
+ if (process.platform !== "darwin") return undefined
56
+ return ["Antigravity IDE", "Antigravity"].find((n) => existsSync(`/Applications/${n}.app`))
57
+ }
58
+
59
+ export function openPlan(ref: SessionRef, label: string = ref.tool, home?: string): OpenPlan {
60
+ const cwd = ref.cwd
61
+ // Antigravity on the 100xprompt gateway (brain gateway connect --tool antigravity): only its launcher starts it with the
62
+ // gateway's models, so the session is opened there (the CLI, started from a shell, would use Google's).
63
+ if (ref.tool === "antigravity" && home && process.platform === "darwin" && existsSync(launcherApp(home)))
64
+ return { command: `open -a ${shellQuote(LAUNCHER_NAME)}`, note: `then pick "${ref.title ?? ref.sourceId}" in its Agent Manager` }
65
+ // Antigravity: its CLI reopens a conversation by id; without the CLI, the IDE app opens and the person picks it.
66
+ if (ref.tool === "antigravity" && !Bun.which("agy")) {
67
+ const app = antigravityApp()
68
+ if (app) return { command: `open -a ${shellQuote(app)}`, note: `then pick "${ref.title ?? ref.sourceId}" in its Agent Manager` }
69
+ }
70
+ const resume = IN_FOLDER[ref.tool]
71
+ if (resume) {
72
+ const cmd = resume(ref.sourceId, cwd ?? "")
73
+ return cwd ? { command: `cd ${shellQuote(cwd)} && ${cmd}` } : { command: cmd, note: `run it in the session's project folder (${label} did not record it)` }
74
+ }
75
+ const app = APPS[ref.tool]
76
+ // Desktop apps open on their own sessions: no folder to give them.
77
+ const noFolder = ref.tool === "claude-desktop" || ref.tool === "codex-desktop"
78
+ const appCmd = app && (cwd || noFolder) ? app(cwd ?? "") : undefined
79
+ const where = ref.tool === "claude-desktop" ? "in the Code tab's sessions" : ref.tool === "codex-desktop" ? "in its threads" : `from ${label}'s chat history`
80
+ if (appCmd) return { command: appCmd, note: `then pick "${ref.title ?? ref.sourceId}" ${where}` }
81
+ return { none: `brain can't reopen ${label} sessions by itself: open ${label}${cwd ? ` in ${cwd}` : ""} and pick "${ref.title ?? ref.sourceId}" from its history (or move it: brain session continue ${ref.tool}:${ref.sourceId} --in <tool>)` }
82
+ }