@100xprompt/brain 0.1.4 → 0.1.5

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 CHANGED
@@ -210,6 +210,19 @@ brain pair # approve this computer in your 100xprompt account (in the
210
210
  brain pair status # paired or not, and the last exchange with the account
211
211
  ```
212
212
 
213
+ To have changes you make in the dashboard apply here by themselves, run brain in the background:
214
+
215
+ ```bash
216
+ brain service install # macOS (launchd) or Linux (systemd --user); every minute it takes and applies dashboard changes
217
+ brain service status # installed, running, and its last round
218
+ brain service uninstall
219
+ ```
220
+
221
+ It uses the folders and opt-outs your shell had when you last ran a brain command (`~/.brain/env.json`), restarts itself
222
+ when you update brain, and waits longer between tries while you are offline. Its log is `~/.brain/logs/service.log`. Run
223
+ `brain service uninstall` before removing brain (if you forget, it stops by itself). Without it, `brain status` or
224
+ `brain sync` take dashboard changes by hand.
225
+
213
226
  Pairing gives this computer its own token (`~/.brain/machine.key`, readable only by you), separate from the gateway key
214
227
  your tools use: model keys never read or change settings. A paired computer sends the changes made here to your account
215
228
  and takes the ones made there when you run `brain status`, `brain settings` or `brain sync` (a few seconds at most; the
@@ -266,6 +279,7 @@ brain status
266
279
  brain settings [list | get | set | unset | history | ids]
267
280
  brain sync
268
281
  brain pair [status | forget]
282
+ brain service install | uninstall | status
269
283
  brain org login | logout | whoami | status | promote | demote | forget | admin ...
270
284
  brain expand <id>
271
285
  brain --version
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@100xprompt/brain",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "description": "One working session, any AI coding tool: move sessions between Claude Code, Codex, Cursor, Gemini CLI, OpenCode and more, keep what projects learned, spend fewer tokens. Runs on Bun. By 100xprompt.",
5
5
  "license": "MIT",
6
6
  "author": "Nipurn Agarwal",
package/src/cli.ts CHANGED
@@ -45,6 +45,7 @@ brain status this computer at a glance: si
45
45
  brain settings [list|get|set|unset|history|ids] brain's settings on this computer: who set each, and whether it is applied (\`brain settings help\`)
46
46
  brain sync exchange settings with your account, apply what changed or failed, and send this computer's report now
47
47
  brain pair [--no-browser] | status | forget approve this computer in your 100xprompt account, so its settings show in the dashboard
48
+ brain service install | uninstall | status run brain in the background, so changes made in your dashboard apply here by themselves
48
49
  brain --version the version, and where this brain runs from
49
50
  brain expand <blob id> the full text of a tool output that was replaced by a digest`
50
51
 
@@ -231,7 +232,14 @@ async function main() {
231
232
  }
232
233
  if (cmd === "status") {
233
234
  const { statusCommand } = await import("./settings/cli.ts")
234
- process.exit(await statusCommand(home))
235
+ let changed = false
236
+ const code = await statusCommand(home, undefined, undefined, { changed: () => (changed = true) })
237
+ if (changed) await reportLater()
238
+ process.exit(code)
239
+ }
240
+ if (cmd === "service") {
241
+ const { serviceCommand } = await import("./service.ts")
242
+ process.exit(await serviceCommand(rest, home))
235
243
  }
236
244
  if (cmd === "pair") {
237
245
  const { pairCommand } = await import("./settings/pair.ts")
@@ -76,12 +76,16 @@ export interface SettingsRow {
76
76
  error?: string
77
77
  notes?: string[]
78
78
  remote: boolean
79
+ /** A change made here to this setting (any level) not sent to the account yet: being the later write, it will win. */
80
+ pending?: true
79
81
  }
80
82
 
81
83
  export interface SettingsSection {
82
84
  paired: boolean
83
85
  lastSyncAt: string | null
84
86
  waiting: number
87
+ /** brain's background service on this computer (docs/settings-contract.md, section 6). */
88
+ service: { installed: boolean; running: boolean; lastRunAt: string | null }
85
89
  rows: SettingsRow[]
86
90
  }
87
91
 
@@ -97,6 +101,9 @@ export async function settingsSection(home: string, env: Env): Promise<SettingsS
97
101
  const rows = await withTimeout(listSettings(home, env), 5_000)
98
102
  if (!rows) return undefined
99
103
  const st = exchangeState(home)
104
+ const { serviceState } = await import("../service.ts")
105
+ const { readSettingsFile } = await import("../settings/store.ts")
106
+ const unsent = new Set(readSettingsFile(home).doc.outbox.map((c) => c.id))
100
107
  const short = (v: unknown, type: { kind: string; choices?: string[] }): boolean | number | string | null => {
101
108
  if (v === undefined || v === null) return null
102
109
  if (typeof v === "boolean" || typeof v === "number") return v
@@ -108,6 +115,7 @@ export async function settingsSection(home: string, env: Env): Promise<SettingsS
108
115
  paired: st.paired,
109
116
  lastSyncAt: st.lastSyncAt ?? null,
110
117
  waiting: st.waiting,
118
+ service: serviceState(home),
111
119
  rows: rows.slice(0, 400).map((r): SettingsRow => {
112
120
  const t = r.family.type
113
121
  return {
@@ -126,6 +134,7 @@ export async function settingsSection(home: string, env: Env): Promise<SettingsS
126
134
  ...(r.error ? { error: clip(cleanText(r.error, home), 300) } : {}),
127
135
  ...(r.notes.length ? { notes: r.notes.slice(0, 5).map((n) => clip(cleanText(n, home), 300)) } : {}),
128
136
  remote: r.family.remote === "yes",
137
+ ...(st.paired && unsent.has(r.id) ? { pending: true as const } : {}),
129
138
  }
130
139
  }),
131
140
  }
package/src/lock.ts CHANGED
@@ -93,6 +93,9 @@ export function processStart(pid: number, now = Date.now()): number | undefined
93
93
  let started: number | undefined
94
94
  const myStart = () => (started ??= processStart(process.pid) ?? Math.round(performance.timeOrigin))
95
95
 
96
+ /** This process's start, as holderGone() judges this process (what a loop records to be judged alive by itself too). */
97
+ export const ownStart = () => myStart()
98
+
96
99
  function readOwner(home: string): LockOwner | undefined {
97
100
  try {
98
101
  const o = JSON.parse(readFileSync(ownerPath(home), "utf8"))
package/src/service.ts ADDED
@@ -0,0 +1,473 @@
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
+ env?: Env
328
+ fetchImpl?: typeof fetch
329
+ say?: (line: string) => void
330
+ cli?: string
331
+ }
332
+
333
+ type Turn = { ours: true } | { ours: false; pid: number }
334
+
335
+ /**
336
+ * This loop's turn for the home, read, judged and written under the brain lock (two loops starting at once never both
337
+ * run): another live loop keeps it; otherwise `state` is written as this loop's. Called every round, so a loop that lost
338
+ * its turn stops exchanging.
339
+ */
340
+ function keepTurn(home: string, state: ServiceState, every: number): Turn {
341
+ return withBrainLockSync(
342
+ home,
343
+ () => {
344
+ const cur = readServiceState(home)
345
+ const mine = cur.pid === state.pid && cur.startedAt === state.startedAt
346
+ // Another loop has the turn while its process lives and it has run a round lately (as serviceState judges running):
347
+ // a process id reused by something else, where its start cannot be told (no ps), never holds it for good.
348
+ const last = cur.lastRunAt ? Date.parse(cur.lastRunAt) : cur.startedAt
349
+ const lately = typeof last === "number" && Number.isFinite(last) && Date.now() - last < Math.max(every, 60_000) * 12
350
+ if (!mine && lately && typeof cur.pid === "number" && typeof cur.startedAt === "number" && cur.pid !== process.pid && !holderGone({ pid: cur.pid, startedAt: cur.startedAt, at: "" }))
351
+ return { ours: false, pid: cur.pid }
352
+ writeServiceState(home, state)
353
+ return { ours: true }
354
+ },
355
+ { what: "the background service's turn" },
356
+ )
357
+ }
358
+
359
+ /**
360
+ * `brain service run`: the loop. Returns when it should stop: the brain on disk changed ("updated"), brain was removed
361
+ * from disk ("gone"), or (tests) after `rounds` ("another" when another loop had the home all along). A round that fails
362
+ * in any way is a failed round, never the end of the process.
363
+ */
364
+ export async function runService(home: string, o: LoopOptions = {}): Promise<"stopped" | "another" | "updated" | "gone"> {
365
+ const env = o.env ?? process.env
366
+ const say = o.say ?? ((l: string) => process.stdout.write(`${new Date().toISOString()} ${l}\n`))
367
+ const cli = o.cli ?? CLI_PATH
368
+ // Recorded as this process judges itself (lock.ts), so its own reports say it runs.
369
+ const state: ServiceState = { pid: process.pid, startedAt: ownStart(), version: VERSION }
370
+ say(`brain service ${VERSION} started`)
371
+ const { exchange } = await import("./settings/account.ts")
372
+ const { sendingOff, sendReport, canReport } = await import("./dashboard/send.ts")
373
+ let failures = 0
374
+ let waiting: number | undefined
375
+ let everOurs = false
376
+ for (let round = 1; ; round++) {
377
+ let wait = loopEvery(env)
378
+ try {
379
+ refreshEnv(home, env)
380
+ // Gone twice, 2 s apart: `bun add -g` replaces the package with cli.ts missing for a few milliseconds.
381
+ if (!existsSync(cli) && (await Bun.sleep(2_000), !existsSync(cli))) {
382
+ say("brain is no longer on this computer: this service stops (brain service uninstall removes what is left)")
383
+ return "gone"
384
+ }
385
+ const now = versionOnDisk(cli)
386
+ if (now && now !== VERSION) {
387
+ say(`brain on disk is now ${now}: this ${VERSION} stops, and the system starts the new one`)
388
+ if (everOurs) keepTurn(home, { ...state, lastRunAt: new Date().toISOString() }, loopEvery(env))
389
+ return "updated"
390
+ }
391
+ const turn = keepTurn(home, state, loopEvery(env))
392
+ if (!turn.ours) {
393
+ if (waiting !== turn.pid) say(`another brain service runs for this home (process ${turn.pid}); this one waits until it stops`)
394
+ waiting = turn.pid
395
+ if (o.rounds && round >= o.rounds) return everOurs ? "stopped" : "another"
396
+ await Bun.sleep(wait)
397
+ continue
398
+ }
399
+ if (waiting !== undefined) say("the other brain service stopped: this one takes over")
400
+ waiting = undefined
401
+ everOurs = true
402
+ const x = await exchange(home, { env, timeoutMs: 10_000, ...(o.fetchImpl ? { fetchImpl: o.fetchImpl } : {}) })
403
+ const at = new Date().toISOString()
404
+ if (!x.paired) {
405
+ state.lastRunAt = at
406
+ delete state.lastError
407
+ } else if (x.ok) {
408
+ failures = 0
409
+ Object.assign(state, { lastRunAt: at, lastOkAt: at })
410
+ delete state.lastError
411
+ } else {
412
+ failures++
413
+ Object.assign(state, { lastRunAt: at, lastError: x.why ?? "unknown" })
414
+ say(`round failed: ${x.why}`)
415
+ }
416
+ // Written before the report: the report says this loop runs.
417
+ keepTurn(home, state, loopEvery(env))
418
+ if (x.ok && (x.received.length || x.applied.length)) {
419
+ say(`took ${x.received.length} change(s) from your account, applied ${x.applied.length}`)
420
+ // The dashboard sees the result at once.
421
+ if (canReport(home) && !sendingOff(home, env)) {
422
+ try {
423
+ const { buildReport } = await import("./dashboard/report.ts")
424
+ const r = await sendReport(home, await buildReport(home, { env }), o.fetchImpl ? { fetchImpl: o.fetchImpl } : {})
425
+ if (!r.ok) say(`report not sent: ${r.why}`)
426
+ } catch {}
427
+ }
428
+ }
429
+ wait = backoff(loopEvery(env), failures)
430
+ } catch (e) {
431
+ // The brain lock busy for long, a disk that is full, anything else: this round failed, the loop goes on.
432
+ failures++
433
+ state.lastRunAt = new Date().toISOString()
434
+ state.lastError = `the round stopped: ${String((e as Error)?.message ?? e).slice(0, 200)}`
435
+ say(state.lastError)
436
+ try {
437
+ if (everOurs) keepTurn(home, state, loopEvery(env))
438
+ } catch {}
439
+ wait = backoff(loopEvery(env), failures)
440
+ }
441
+ if (o.rounds && round >= o.rounds) return "stopped"
442
+ await Bun.sleep(wait)
443
+ }
444
+ }
445
+
446
+ // ---- the command ---------------------------------------------------------------------------------------------------------
447
+
448
+ const USAGE = [
449
+ "brain service install run brain in the background, so changes made in your dashboard apply here by themselves",
450
+ "brain service uninstall stop it and remove it",
451
+ "brain service status whether it is installed and running, and its last round",
452
+ ]
453
+
454
+ export async function serviceCommand(args: string[], home: string, io: Io = { out: (l) => console.log(l) }): Promise<number> {
455
+ const sub = args[0]
456
+ switch (sub) {
457
+ case "install":
458
+ return installService(home, io)
459
+ case "uninstall":
460
+ return uninstallService(home, io)
461
+ case "status":
462
+ case undefined:
463
+ serviceStatusLines(home).forEach((l) => io.out(l))
464
+ return 0
465
+ case "run":
466
+ // Only a newer brain on disk (or a crash) has the system start it again; brain removed from disk stays stopped.
467
+ return (await runService(home)) === "updated" ? RESTART_EXIT : 0
468
+ default:
469
+ io.out(`unknown service command "${sub}"`)
470
+ USAGE.forEach((l) => io.out(`usage: ${l}`))
471
+ return 2
472
+ }
473
+ }
@@ -18,7 +18,7 @@ import { gatewayUrl, safeUrl } from "../dashboard/send.ts"
18
18
  import { brainDir, MACHINE_TOKEN, machineId, machineKeyPath, readMachineToken } from "../dashboard/state.ts"
19
19
  import { readOrgConfig } from "../org/config.ts"
20
20
  import { type Row, reconcile } from "./apply.ts"
21
- import { checkValue, parseId } from "./catalog.ts"
21
+ import { checkValue, parseId, resolve } from "./catalog.ts"
22
22
  import { type Entry, type Level, readSettingsFile, same, type SettingsDoc, type Source, updateDoc, type Value } from "./store.ts"
23
23
  import { memoryOn } from "./values.ts"
24
24
 
@@ -40,14 +40,16 @@ export function forgetMachineToken(home: string): boolean {
40
40
  /**
41
41
  * A computer the account knows afresh (paired again after "Remove this machine", or a copied ~/.brain paired as a computer
42
42
  * of its own) has an empty document there: every value set at this computer's level here is sent again, and the next fetch
43
- * starts from nothing, so the account's empty document never erases them.
43
+ * starts from nothing, so the account's empty document never erases them. Not the dashboard's or an admin's: those are the
44
+ * account's to keep (a computer never sends them as its own; the gateway refuses that), and an account that forgot this
45
+ * computer forgot them too.
44
46
  */
45
47
  export function sendAllAgain(home: string): void {
46
48
  updateDoc(home, (d) => {
47
49
  const at = new Date().toISOString()
48
50
  for (const [id, e] of Object.entries(d.levels.computer)) {
49
51
  const ref = parseId(id)
50
- if ("error" in ref || ref.family.remote !== "yes") continue
52
+ if ("error" in ref || ref.family.remote !== "yes" || !COMPUTER_SOURCES.includes(e.source)) continue
51
53
  d.outbox = d.outbox.filter((c) => !(c.id === id && c.level === "computer"))
52
54
  d.outbox.push({ id, level: "computer", value: e.value, source: e.source, at: e.at || at, ...(e.command ? { command: e.command } : {}) })
53
55
  }
@@ -73,6 +75,8 @@ export interface Exchange {
73
75
 
74
76
  const LEVEL_OUT: Record<"computer" | "person", "machine" | "account"> = { computer: "machine", person: "account" }
75
77
  const SOURCES: Source[] = ["settings", "command", "dashboard", "admin", "local-page"]
78
+ /** The sources a computer sends its changes with; the dashboard's and an admin's come only from the account. */
79
+ const COMPUTER_SOURCES: Source[] = ["settings", "command", "local-page"]
76
80
  const isValue = (v: unknown): v is Value => typeof v === "boolean" || (typeof v === "number" && Number.isFinite(v)) || typeof v === "string"
77
81
  const obj = (v: unknown): Record<string, any> => (v && typeof v === "object" && !Array.isArray(v) ? (v as Record<string, any>) : {})
78
82
  const isObj = (v: unknown) => !!v && typeof v === "object" && !Array.isArray(v)
@@ -87,6 +91,9 @@ function entryFrom(id: string, level: Level, e: unknown): Entry | undefined {
87
91
  return { value: x.value, source: SOURCES.includes(x.source) ? x.source : "dashboard", at: x.at, ...(Number.isSafeInteger(x.rev) ? { rev: x.rev } : {}) }
88
92
  }
89
93
 
94
+ const sameEntry = (x: Entry | undefined, y: Entry | undefined) =>
95
+ x === y || (!!x && !!y && same(x.value, y.value) && x.source === y.source && x.at === y.at && x.rev === y.rev)
96
+
90
97
  /** "<machine>.<account>" as two numbers; undefined when it is not one. */
91
98
  const revParts = (r: unknown): [number, number] | undefined => {
92
99
  const m = typeof r === "string" ? /^(\d{1,15})\.(\d{1,15})$/.exec(r) : null
@@ -120,8 +127,6 @@ export async function exchange(home: string, o: { env?: Env; fetchImpl?: typeof
120
127
  if (file.problem) return { ...out, why: file.problem === "newer" ? "settings.json was written by a newer brain: update brain to exchange settings here" : "settings.json could not be read: `brain settings set` writes a new one" }
121
128
  const base = gatewayUrl(home)
122
129
  if (!safeUrl(`${base}/api/brain/settings`)) return { ...out, why: `the gateway address ${base} is not https; the token is never sent over it` }
123
- // A computer the account knows afresh: everything set here goes again (see sendAllAgain).
124
- if (file.doc.sync.machine !== machine) sendAllAgain(home)
125
130
  const deadline = Date.now() + (o.timeoutMs ?? 3_000)
126
131
  const call = async (method: string, path: string, body?: unknown): Promise<{ status: number; json: unknown }> => {
127
132
  const left = deadline - Date.now()
@@ -162,6 +167,14 @@ export async function exchange(home: string, o: { env?: Env; fetchImpl?: typeof
162
167
  }
163
168
 
164
169
  try {
170
+ // A computer the account knows afresh: everything set here goes again (see sendAllAgain).
171
+ if (file.doc.sync.machine !== machine) sendAllAgain(home)
172
+ // A change this computer cannot send as its own (an older brain queued the dashboard's) would wait for good and keep
173
+ // the account's value from being taken: it leaves the outbox.
174
+ if (readSettingsFile(home).doc.outbox.some((c) => !COMPUTER_SOURCES.includes(c.source)))
175
+ updateDoc(home, (d) => {
176
+ d.outbox = d.outbox.filter((c) => COMPUTER_SOURCES.includes(c.source))
177
+ })
165
178
  // 1. Send what changed here.
166
179
  const snapshot = readSettingsFile(home).doc.outbox
167
180
  for (const level of ["computer", "person"] as const) {
@@ -220,6 +233,10 @@ export async function exchange(home: string, o: { env?: Env; fetchImpl?: typeof
220
233
  const stored = revParts(d.sync.rev)
221
234
  if (stored && (fetched[0] < stored[0] || fetched[1] < stored[1])) return
222
235
  const pending = new Set(d.outbox.map((c) => `${c.level}\0${c.id}`))
236
+ // The levels as they were, to tell what taking the answer changes in the value in force here.
237
+ const before: SettingsDoc = { ...d, levels: { ...d.levels } }
238
+ // The same value written again on the account (a newer rev at another time: the dashboard's "try again").
239
+ const again: string[] = []
223
240
  const next: Record<"computer" | "person", Record<string, Entry>> = { computer: {}, person: {} }
224
241
  for (const [level, srv] of [
225
242
  ["computer", j.machine.entries],
@@ -235,8 +252,14 @@ export async function exchange(home: string, o: { env?: Env; fetchImpl?: typeof
235
252
  const e = entryFrom(id, level, raw)
236
253
  if (!e) continue
237
254
  const was = d.levels[level][id]
238
- // The same value from the same source: kept as it is here (with the command that set it).
239
- next[level][id] = was && same(was.value, e.value) && was.source === e.source ? { ...was, ...(e.rev !== undefined ? { rev: e.rev } : {}) } : e
255
+ if (was && same(was.value, e.value) && was.source === e.source) {
256
+ if (e.rev !== undefined && was.rev !== undefined && e.rev > was.rev && e.at !== was.at) {
257
+ next[level][id] = { ...e, ...(was.command ? { command: was.command } : {}) }
258
+ again.push(id)
259
+ }
260
+ // The same value from the same source: kept as it is here (with the command that set it).
261
+ else next[level][id] = { ...was, ...(e.rev !== undefined ? { rev: e.rev } : {}) }
262
+ } else next[level][id] = e
240
263
  }
241
264
  }
242
265
  // Memory turned on from afar on a computer that sends it to a team server: only this computer turns that on. Judged
@@ -275,6 +298,20 @@ export async function exchange(home: string, o: { env?: Env; fetchImpl?: typeof
275
298
  const locks: SettingsDoc["locks"] = {}
276
299
  for (const [id, l] of Object.entries(obj(j.locks))) if (Object.hasOwn(obj(j.locks), id) && isValue(obj(l).value) && !("error" in parseId(id))) locks[id] = { value: obj(l).value }
277
300
  d.locks = locks
301
+ // The value in force here set from the account (set, set again, the same value from another source, or cleared down
302
+ // to another level's equal value) is applied afresh, even where it failed or was changed by hand since: that is the
303
+ // person setting it (again). A change at a level that does not decide here applies nothing.
304
+ // A setting with nothing to apply set again is still taken, so what it gave is said (below, after applying).
305
+ for (const id of new Set([...out.received, ...again])) {
306
+ const ref = parseId(id)
307
+ if ("error" in ref) continue
308
+ const a = resolve(d, ref)
309
+ if (a.value === undefined || (a.from !== "computer" && a.from !== "person" && a.from !== "team")) continue
310
+ const b = resolve(before, ref)
311
+ if (b.from === a.from && sameEntry(b.entry, a.entry)) continue
312
+ if (ref.family.apply) delete d.applied[id]
313
+ if (!out.received.includes(id)) out.received.push(id)
314
+ }
278
315
  d.history = d.history.slice(-200)
279
316
  d.sync.rev = `${fetched[0]}.${fetched[1]}`
280
317
  d.sync.lastSyncAt = now
@@ -295,13 +332,31 @@ export async function exchange(home: string, o: { env?: Env; fetchImpl?: typeof
295
332
 
296
333
  // 4. Apply what changed, and say what it gave (a value held back here counts as failed).
297
334
  if (o.apply !== false) {
335
+ let elsewhere: string[] = []
298
336
  try {
299
- out.applied = (await reconcile(home, { env: o.env ?? process.env })).applied
337
+ const r = await reconcile(home, { env: o.env ?? process.env })
338
+ out.applied = r.applied
339
+ elsewhere = r.elsewhere
300
340
  } catch {}
301
341
  const applied: Record<string, unknown> = {}
342
+ const at = new Date().toISOString()
302
343
  for (const row of out.applied)
303
- applied[row.id] = { want: row.value ?? null, ok: row.state !== "failed" && row.state !== "not on this computer" && row.state !== "can't read", ...(row.error ? { error: row.error } : {}), ...(row.notes.length ? { notes: row.notes.slice(0, 5) } : {}), at: new Date().toISOString() }
304
- for (const h of out.held) applied[h.id] = { want: true, ok: false, error: h.why, at: new Date().toISOString() }
344
+ applied[row.id] = { want: row.value ?? null, ok: row.state !== "failed" && row.state !== "not on this computer" && row.state !== "can't read", ...(row.error ? { error: row.error } : {}), ...(row.notes.length ? { notes: row.notes.slice(0, 5) } : {}), at }
345
+ // A value taken that has nothing to apply (brain reads it where it is used: lean.*, memory.enabled) is in force once
346
+ // taken; one for a tool not on this computer waits for that tool. Both are said, so the dashboard never waits on them.
347
+ // (A value in force set from the account is applied afresh above, so it is among the applied rows.)
348
+ if (out.received.length) {
349
+ const doc = readSettingsFile(home).doc
350
+ for (const id of out.received) {
351
+ const ref = parseId(id)
352
+ if ("error" in ref || Object.hasOwn(applied, id) || out.held.some((h) => h.id === id)) continue
353
+ const want = resolve(doc, ref).value
354
+ if (want === undefined) continue
355
+ if (!ref.family.apply) applied[id] = { want, ok: true, at }
356
+ else if (elsewhere.includes(id)) applied[id] = { want, ok: false, error: `${ref.tool ?? "this tool"} is not on this computer: brain applies it once it is`, at }
357
+ }
358
+ }
359
+ for (const h of out.held) applied[h.id] = { want: true, ok: false, error: h.why, at }
305
360
  if (Object.keys(applied).length && Date.now() < deadline) {
306
361
  try {
307
362
  await call("POST", "/api/brain/settings/applied", { machine, applied })
@@ -173,6 +173,9 @@ async function withAccount(home: string, env: Env, say: (l: string) => void, fet
173
173
  return x
174
174
  }
175
175
 
176
+ /** An exchange that took or applied something changed what the report says (a cleared value shows only there). */
177
+ export const tookSomething = (x: Exchange | undefined) => !!x?.ok && (x.received.length > 0 || x.applied.length > 0)
178
+
176
179
  /**
177
180
  * `brain settings ...`. `hooks.changed` is called when a set or unset saved something (the dashboard report is sent then,
178
181
  * not after a refusal). A paired computer exchanges with the account first (and, after a change, again).
@@ -195,7 +198,7 @@ export async function settingsCommand(
195
198
  if (file.problem === "damaged") note(`note: ${settingsFile(home)} could not be read: brain's defaults apply (memory counts as off), and it is kept aside on the next change`)
196
199
  // What the account changed is taken (and applied) before anything is shown or changed here; the whole command keeps to 3 s.
197
200
  const deadline = Date.now() + 3_000
198
- if (["list", "get", "history", "set", "unset"].includes(sub) && !file.problem) await withAccount(home, env, note, hooks.fetchImpl, deadline)
201
+ if (["list", "get", "history", "set", "unset"].includes(sub) && !file.problem && tookSomething(await withAccount(home, env, note, hooks.fetchImpl, deadline))) hooks.changed?.()
199
202
  switch (sub) {
200
203
  case "list": {
201
204
  const ti = rest.indexOf("--tool")
@@ -262,7 +265,7 @@ export async function settingsCommand(
262
265
  if (o.change) hooks.changed?.()
263
266
  const code = printOutcome(o, io, sub === "set" ? "saved" : "cleared")
264
267
  // The change goes to the account at once.
265
- if (o.change) await withAccount(home, env, io.out, hooks.fetchImpl, deadline)
268
+ if (o.change && tookSomething(await withAccount(home, env, io.out, hooks.fetchImpl, deadline))) hooks.changed?.()
266
269
  return code
267
270
  }
268
271
  case "history": {
@@ -319,8 +322,10 @@ const productName = (alias: unknown) => (typeof alias === "string" && MODEL_ALIA
319
322
  * `brain status`: this computer at a glance, with whether its settings are applied. On a paired computer it first exchanges
320
323
  * with the account (so a change made in the dashboard is taken and applied); otherwise it only reads.
321
324
  */
322
- export async function statusCommand(home: string, io: Io = { out: (l) => console.log(l) }, env: Env = process.env, deps: { fetchImpl?: typeof fetch } = {}): Promise<number> {
325
+ export async function statusCommand(home: string, io: Io = { out: (l) => console.log(l) }, env: Env = process.env, deps: { fetchImpl?: typeof fetch; changed?: () => void } = {}): Promise<number> {
323
326
  const exchanged = readSettingsFile(home).problem ? undefined : await exchange(home, { env, ...(deps.fetchImpl ? { fetchImpl: deps.fetchImpl } : {}) })
327
+ // What it took from the account goes to the dashboard in a report too (the computer may have no background service).
328
+ if (tookSomething(exchanged)) deps.changed?.()
324
329
  const { VERSION } = await import("../self.ts")
325
330
  const { machineName, gatewayState } = await import("../dashboard/report.ts")
326
331
  const { machineId, readCache } = await import("../dashboard/state.ts")
@@ -392,6 +397,8 @@ export async function statusCommand(home: string, io: Io = { out: (l) => console
392
397
  for (const h of acct.held) io.out(` ${h.id}: not taken from your account: ${h.why}`)
393
398
  const refused = acct.refused.slice(-3)
394
399
  if (refused.length) io.out(` refused by your account (this computer went back to its value): ${refused.map((r) => `${r.id} = ${String(r.value)} (${r.message || r.code})`).join("; ")}`)
400
+ const { serviceStatusLines } = await import("../service.ts")
401
+ if (acct.paired) serviceStatusLines(home).forEach((l) => io.out(l))
395
402
  const recorded = readRecordedEnv(home)
396
403
  // As the code that obeys them reads them: BRAIN_NO_REPORT anything but empty, 0 or false; BRAIN_NO_MEMORY exactly 1.
397
404
  const envOff = [...(off && /BRAIN_NO_REPORT/.test(off) ? ["BRAIN_NO_REPORT"] : []), ...(env.BRAIN_NO_MEMORY === "1" ? ["BRAIN_NO_MEMORY"] : [])]
@@ -95,6 +95,8 @@ export function isPersonsCommand(argv: string[]): boolean {
95
95
  // The session hook (run by a tool), the MCP server (started by a tool), and the background children brain starts.
96
96
  if (cmd === "hook" || (cmd === "session" && rest[0] === "hook")) return false
97
97
  if (cmd === "mcp" && rest[0] === "serve") return false
98
+ // The background service is started by launchd or systemd, with the environment it was given, not the person's shell.
99
+ if (cmd === "service" && rest[0] === "run") return false
98
100
  if (cmd === "dashboard" && rest[0] === "push" && (rest.includes("--quiet") || rest.includes("--at"))) return false
99
101
  if (cmd === "memory" && rest[0] === "sync" && rest.includes("--quiet")) return false
100
102
  return true
@@ -79,5 +79,8 @@ export async function pairCommand(args: string[], home: string, io: Io = { out:
79
79
  io.out(`paired${had ? " again" : ""}: ${machineName()} is linked to your 100xprompt account; its token is in ~/.brain/machine.key (readable only by you).`)
80
80
  io.out(x.ok ? `settings: ${x.sent} change${x.sent === 1 ? "" : "s"} made here sent to your account; the dashboard's Brain view shows this computer's settings.` : `settings: not exchanged yet (${x.why}); \`brain sync\` tries again.`)
81
81
  if (x.ok) for (const l of exchangeLines({ ...x, sent: 0 }, 0)) io.out(l)
82
+ const { serviceState } = await import("../service.ts")
83
+ // macOS and Linux only for now; elsewhere `brain sync` takes the dashboard's changes.
84
+ if ((process.platform === "darwin" || process.platform === "linux") && !serviceState(home).installed) io.out("next: `brain service install` runs brain in the background, so changes you make in the dashboard apply here by themselves.")
82
85
  return 0
83
86
  }