@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/moves.ts ADDED
@@ -0,0 +1,402 @@
1
+ // Moves asked from the dashboard (docs/sessions-contract.md, sections 2-3): "move this session to that tool", by ids only.
2
+ // Each round the background service takes the computer's waiting moves and, one at a time:
3
+ // 1. claims it (`asking`): from then on the dashboard cannot cancel it and no other round takes it again;
4
+ // 2. finds the session here by its exact id (never a shortened one), and checks the target is a tool brain can write;
5
+ // 3. as `moves.fromDashboard` says (ask by default): asks the person on this computer, showing the session brain found
6
+ // (its own title, folder and tool: never what the website called it), moves without asking, or refuses;
7
+ // 4. moves it as `brain session continue <id> --in <tool> --no-open` would, and tells the account how it went (a result
8
+ // that could not be sent is kept and sent again next round).
9
+ // Where there is no way to ask (no desktop), the move waits here for `brain moves approve <id>`. Nothing the website sends
10
+ // is ever run as a command.
11
+ import { readFileSync } from "node:fs"
12
+ import { basename, join } from "node:path"
13
+ import type { SessionRef, Way } from "./adapters/types.ts"
14
+ import { gatewayUrl, safeUrl } from "./dashboard/send.ts"
15
+ import { brainDir, machineId, readMachineToken, writePrivateJson } from "./dashboard/state.ts"
16
+ import { withBrainLockSync } from "./lock.ts"
17
+ import { cleanText } from "./settings/catalog.ts"
18
+ import { type MovesSetting, movesSetting } from "./settings/values.ts"
19
+
20
+ export const MOVES_PATH = "/api/brain/moves"
21
+ const WAYS: Way[] = ["native", "import", "brief"]
22
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: control characters are what it refuses
23
+ const SESSION_ID = /^[a-z0-9][a-z0-9-]{0,39}:[^\s\u0000-\u001f\u007f-\u009f\u061c\u200b-\u200f\u202a-\u202e\u2060-\u2069\ufeff]{1,190}$/
24
+ const TOOL = /^[a-z0-9][a-z0-9-]{0,39}$/
25
+ const MOVE_ID = /^[A-Za-z0-9_-]{8,64}$/
26
+
27
+ export interface Move {
28
+ id: string
29
+ session: string
30
+ title: string | null
31
+ from: string
32
+ to: string
33
+ way: Way | null
34
+ state: string
35
+ }
36
+
37
+ export interface MoveResult {
38
+ way?: string
39
+ open?: string
40
+ newSession?: string | null
41
+ notes?: string[]
42
+ error?: string
43
+ at: string
44
+ }
45
+
46
+ /** Whether the person said yes, no, did not answer in time, or cannot be asked here. */
47
+ export type Answer = "yes" | "no" | "timeout" | "unavailable"
48
+ export type Ask = (question: string) => Answer
49
+
50
+ /**
51
+ * Text for the person (a dialog, `brain moves`): one line, without control, bidirectional or zero-width characters, so
52
+ * text from a session or the website cannot rewrite the line, hide words or reorder them.
53
+ */
54
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: control characters are what it removes
55
+ export const plainText = (s: string, max = 80) => s.replace(/[\u0000-\u001f\u007f-\u009f؜​-‏‪-‮⁠-⁩]/g, " ").replace(/\s+/g, " ").trim().slice(0, max)
56
+
57
+ // ---- what waits here ------------------------------------------------------------------------------------------------------
58
+
59
+ /** Moves waiting for the person here (no way to ask when they came), and results the account has not taken yet. */
60
+ export const pendingPath = (home: string) => join(brainDir(home), "moves.json")
61
+ type PendingMove = Move & { askedAt: string }
62
+ interface Unsent {
63
+ id: string
64
+ state: "done" | "refused" | "failed"
65
+ result: MoveResult
66
+ at: string
67
+ }
68
+ interface Local {
69
+ moves: PendingMove[]
70
+ unsent: Unsent[]
71
+ }
72
+ /** The account lets a move wait an hour (a claimed one longer); one waiting here longer than a day is dropped. */
73
+ export const PENDING_FOR_MS = 60 * 60_000
74
+ const UNSENT_FOR_MS = 24 * 60 * 60_000
75
+
76
+ function readLocal(home: string, now = Date.now()): Local {
77
+ try {
78
+ const j = JSON.parse(readFileSync(pendingPath(home), "utf8"))
79
+ const moves = (Array.isArray(j?.moves) ? j.moves : []).filter((m: any) => m && typeof m.id === "string" && typeof m.session === "string" && typeof m.to === "string" && now - Date.parse(m.askedAt) < PENDING_FOR_MS)
80
+ const unsent = (Array.isArray(j?.unsent) ? j.unsent : []).filter((u: any) => u && typeof u.id === "string" && typeof u.state === "string" && now - Date.parse(u.at) < UNSENT_FOR_MS)
81
+ return { moves, unsent }
82
+ } catch {
83
+ return { moves: [], unsent: [] }
84
+ }
85
+ }
86
+ /** The file changed under the brain lock: read again, changed, written (a round and `brain moves approve` never lose each other's change). */
87
+ function changeLocal<T>(home: string, change: (l: Local) => T): T {
88
+ return withBrainLockSync(
89
+ home,
90
+ () => {
91
+ const l = readLocal(home)
92
+ const r = change(l)
93
+ writePrivateJson(pendingPath(home), l)
94
+ return r
95
+ },
96
+ { what: "moves from the dashboard" },
97
+ )
98
+ }
99
+ export const readPending = (home: string, now = Date.now()) => ({ moves: readLocal(home, now).moves })
100
+
101
+ // ---- what the account sends -------------------------------------------------------------------------------------------------
102
+
103
+ /** A move as the account sends it, if it is one brain can act on; anything else is left alone. */
104
+ export function moveFrom(raw: unknown): Move | undefined {
105
+ const m = raw && typeof raw === "object" ? (raw as Record<string, unknown>) : {}
106
+ if (typeof m.id !== "string" || !MOVE_ID.test(m.id)) return undefined
107
+ if (typeof m.session !== "string" || !SESSION_ID.test(m.session)) return undefined
108
+ if (typeof m.to !== "string" || !TOOL.test(m.to)) return undefined
109
+ const way = WAYS.includes(m.way as Way) ? (m.way as Way) : null
110
+ const title = typeof m.title === "string" ? plainText(m.title, 120) || null : null
111
+ return { id: m.id, session: m.session, title, from: m.session.slice(0, m.session.indexOf(":")), to: m.to, way, state: typeof m.state === "string" ? m.state : "waiting" }
112
+ }
113
+
114
+ /** The session by its exact `<tool>:<id>`, as `brain session list` prints it: never a shortened id, never another one. */
115
+ export async function exactSession(home: string, id: string): Promise<{ ref: SessionRef } | { why: string }> {
116
+ const i = id.indexOf(":")
117
+ if (i <= 0) return { why: "not a session id" }
118
+ const tool = id.slice(0, i)
119
+ const sid = id.slice(i + 1)
120
+ const { desktopHolds, listSessions, loadAdapters } = await import("./session/ops.ts")
121
+ const { readerFor } = await loadAdapters()
122
+ if (!readerFor(tool)) return { why: `brain does not read ${tool} sessions on this computer` }
123
+ const { refs } = await listSessions(home, tool)
124
+ const ref = refs.find((r) => r.sourceId === sid)
125
+ if (ref) return { ref }
126
+ // An id from a list uploaded before the desktop apps were told apart: the same session, now listed as the app's. Exact
127
+ // ids only, as above.
128
+ if (tool === "claude-code") {
129
+ const desk = (await listSessions(home, "claude-desktop")).refs.find((r) => desktopHolds(r, sid))
130
+ if (desk) return { ref: desk }
131
+ }
132
+ if (tool === "codex") {
133
+ const desk = (await listSessions(home, "codex-desktop")).refs.find((r) => r.sourceId === sid)
134
+ if (desk) return { ref: desk }
135
+ }
136
+ return { why: "this session is not on this computer any more" }
137
+ }
138
+
139
+ // ---- asking the person ------------------------------------------------------------------------------------------------------
140
+
141
+ /** The dialog that asks: macOS (osascript), Linux with a desktop (zenity, kdialog); 2 minutes, then no answer. "Don't move" is the default. */
142
+ export function askOnDesktop(question: string, env: Record<string, string | undefined> = process.env): Answer {
143
+ try {
144
+ if (process.platform === "darwin") {
145
+ // The question goes in as an argument, never into the script's text.
146
+ const p = Bun.spawnSync(
147
+ [
148
+ "osascript",
149
+ "-e",
150
+ "on run argv",
151
+ "-e",
152
+ 'display dialog (item 1 of argv) buttons {"Don’t move", "Move"} default button "Don’t move" cancel button "Don’t move" with title "brain" giving up after 120',
153
+ "-e",
154
+ "end run",
155
+ question,
156
+ ],
157
+ { stdout: "pipe", stderr: "pipe", timeout: 130_000 },
158
+ )
159
+ const out = p.stdout.toString()
160
+ if (/gave up:true/.test(out)) return "timeout"
161
+ if (/button returned:Move\b/.test(out)) return "yes"
162
+ // "Don't move" (the cancel button) exits 1 with "User canceled" (-128); anything else means no dialog could be shown.
163
+ return /-128|canceled/i.test(p.stderr.toString()) ? "no" : "unavailable"
164
+ }
165
+ if (process.platform === "linux" && (env.DISPLAY || env.WAYLAND_DISPLAY)) {
166
+ if (Bun.which("zenity")) {
167
+ const p = Bun.spawnSync(["zenity", "--question", "--no-markup", "--default-cancel", "--title=brain", `--text=${question}`, "--ok-label=Move", "--cancel-label=Don't move", "--timeout=120"], { stdout: "ignore", stderr: "ignore", timeout: 130_000 })
168
+ return p.exitCode === 0 ? "yes" : p.exitCode === 5 ? "timeout" : p.exitCode === 1 ? "no" : "unavailable"
169
+ }
170
+ if (Bun.which("kdialog")) {
171
+ const safe = question.replaceAll("&", "&amp;").replaceAll("<", "&lt;")
172
+ const p = Bun.spawnSync(["kdialog", "--title", "brain", "--yes-label", "Move", "--no-label", "Don't move", "--yesno", safe], { stdout: "ignore", stderr: "ignore", timeout: 130_000 })
173
+ return p.exitCode === 0 ? "yes" : p.exitCode === 1 ? "no" : "unavailable"
174
+ }
175
+ }
176
+ } catch {}
177
+ return "unavailable"
178
+ }
179
+
180
+ /** What the person is asked: the session brain found (its own title, folder and tool), never the website's words. */
181
+ async function questionFor(ref: SessionRef, to: string): Promise<string> {
182
+ const { loadAdapters } = await import("./session/ops.ts")
183
+ const { readerFor } = await loadAdapters()
184
+ const label = (t: string) => plainText(readerFor(t === "cursor" ? "cursor-ide" : t)?.label ?? t, 40)
185
+ const title = plainText(ref.title ?? "", 80) || "an untitled session"
186
+ const folder = ref.cwd ? plainText(basename(ref.cwd), 40) : ""
187
+ return `Move "${title}"${folder ? ` (folder ${folder})` : ""} from ${label(ref.tool)} to ${label(to)}? Asked from your 100xprompt dashboard.`
188
+ }
189
+
190
+ // ---- talking to the account -----------------------------------------------------------------------------------------------
191
+
192
+ type Call = (method: string, path: string, body?: unknown) => Promise<{ status: number; json: any }>
193
+
194
+ function caller(home: string, o: { fetchImpl?: typeof fetch; timeoutMs?: number }): Call | undefined {
195
+ const token = readMachineToken(home)
196
+ const base = gatewayUrl(home)
197
+ if (!token || !safeUrl(`${base}${MOVES_PATH}`)) return undefined
198
+ return async (method, path, body) => {
199
+ const res = await (o.fetchImpl ?? fetch)(`${base}${path}`, {
200
+ method,
201
+ headers: { Authorization: `Bearer ${token}`, ...(body ? { "Content-Type": "application/json" } : {}) },
202
+ ...(body ? { body: JSON.stringify(body) } : {}),
203
+ redirect: "manual",
204
+ signal: AbortSignal.timeout(o.timeoutMs ?? 10_000),
205
+ })
206
+ const text = await res.text().catch(() => "")
207
+ let json: any
208
+ try {
209
+ json = text ? JSON.parse(text) : undefined
210
+ } catch {}
211
+ return { status: res.status, json }
212
+ }
213
+ }
214
+
215
+ /** A change of the move's state: "ok", "gone" (cancelled, expired, or not this computer's: never retried), or "later". */
216
+ async function postState(call: Call, id: string, state: string, result?: MoveResult): Promise<"ok" | "gone" | "later"> {
217
+ try {
218
+ const r = await call("POST", `${MOVES_PATH}/${encodeURIComponent(id)}`, { state, ...(result ? { result } : {}) })
219
+ if (r.status === 200) return "ok"
220
+ return r.status === 409 || r.status === 404 || r.status === 403 ? "gone" : "later"
221
+ } catch {
222
+ return "later"
223
+ }
224
+ }
225
+
226
+ /** A result the account did not take for now is kept and sent again next round. */
227
+ async function sendResult(home: string, call: Call | undefined, id: string, state: Unsent["state"], result: MoveResult): Promise<void> {
228
+ const sent = call ? await postState(call, id, state, result) : "later"
229
+ if (sent === "later")
230
+ try {
231
+ changeLocal(home, (l) => {
232
+ l.unsent = [...l.unsent.filter((u) => u.id !== id), { id, state, result, at: new Date().toISOString() }]
233
+ })
234
+ } catch {}
235
+ }
236
+
237
+ async function resendUnsent(home: string, call: Call): Promise<void> {
238
+ for (const u of readLocal(home).unsent) {
239
+ const sent = await postState(call, u.id, u.state, u.result)
240
+ if (sent !== "later")
241
+ try {
242
+ changeLocal(home, (l) => {
243
+ l.unsent = l.unsent.filter((x) => x.id !== u.id)
244
+ })
245
+ } catch {}
246
+ }
247
+ }
248
+
249
+ // ---- moving ---------------------------------------------------------------------------------------------------------------
250
+
251
+ const cleanNote = (s: string, home: string) => plainText(cleanText(s, home), 300)
252
+
253
+ /**
254
+ * The command that opens the moved session, as the account may see it: no link (a brief's link carries the brief's text),
255
+ * and the home folder, plain or URL-encoded, written ~.
256
+ */
257
+ export function openForAccount(open: string, home: string): string {
258
+ const parts = open.split(/\s*&&\s*/).filter((p) => !/[a-z][a-z0-9+.-]*:\/\//i.test(p) && !/%2F/i.test(p))
259
+ let t = parts.join(" && ")
260
+ if (home) {
261
+ // ~ is expanded only outside quotes: '<home>/My Projects' becomes ~/'My Projects', never '~/My Projects'.
262
+ t = t.replaceAll(`'${home}/`, "~/'").replaceAll(`'${home}'`, "~").replaceAll(`"${home}/`, '~/"').replaceAll(`"${home}"`, "~")
263
+ for (const h of [home, encodeURIComponent(home)]) t = t.replaceAll(h, "~")
264
+ }
265
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: control characters are what it removes
266
+ return cleanText(t, home).replace(/[\u0000-\u001f\u007f-\u009f]/g, " ").slice(0, 500)
267
+ }
268
+
269
+ /** Runs one move here, for the session found by its exact id. */
270
+ export async function runMove(home: string, m: Move, ref: SessionRef): Promise<{ state: "done" | "failed"; result: MoveResult }> {
271
+ const at = () => new Date().toISOString()
272
+ const { loadAdapters, moveSession, readSession } = await import("./session/ops.ts")
273
+ const { writersFor, readerFor } = await loadAdapters()
274
+ if (!writersFor(m.to).length) return { state: "failed", result: { error: `brain can't continue sessions in "${m.to}" on this computer`, at: at() } }
275
+ if (ref.tool === m.to) return { state: "failed", result: { error: "the session is already in that tool", at: at() } }
276
+ try {
277
+ const s = await readSession(home, ref)
278
+ // A move from afar goes to the session's own folder; brain's own folder (the service runs in /) is never a guess for it.
279
+ const cwd = s.header.project.cwd ?? ref.cwd
280
+ if (!cwd) return { state: "failed", result: { error: `${plainText(readerFor(ref.tool)?.label ?? ref.tool, 40)} did not record this session's folder: move it from a terminal in that folder (brain session continue ${ref.tool}:${ref.sourceId} --in ${m.to})`, at: at() } }
281
+ const r = await moveSession(home, s, m.to, { dryRun: false, cwd, ...(m.way ? { way: m.way } : {}) })
282
+ if (!r.ok) return { state: "failed", result: { error: cleanNote(r.why, home), notes: r.notes.slice(0, 5).map((n) => cleanNote(n, home)), at: at() } }
283
+ const w = r.result
284
+ const toReader = w.tool === "cursor" ? "cursor-ide" : readerFor(w.tool) ? w.tool : m.to
285
+ const notes = [...r.notes, ...(w.note ? [`then: ${w.note}`] : []), ...(w.lost.length ? [`not carried over: ${w.lost.join("; ")}`] : [])]
286
+ return {
287
+ state: "done",
288
+ result: { way: w.way, open: openForAccount(w.open, home), newSession: w.sessionId ? `${toReader}:${w.sessionId}`.slice(0, 200) : null, notes: notes.slice(0, 5).map((n) => cleanNote(n, home)), at: at() },
289
+ }
290
+ } catch (e) {
291
+ return { state: "failed", result: { error: cleanNote(`the move stopped: ${String((e as Error)?.message ?? e)}`, home), at: at() } }
292
+ }
293
+ }
294
+
295
+ export interface MovesRound {
296
+ done: number
297
+ refused: number
298
+ failed: number
299
+ /** Left waiting here for `brain moves approve`. */
300
+ pending: number
301
+ /** Why the moves could not be fetched, when they could not. */
302
+ why?: string
303
+ }
304
+
305
+ /** One round: the account's waiting moves, as `moves.fromDashboard` says. At most `max` moves a round. */
306
+ export async function processMoves(home: string, o: { fetchImpl?: typeof fetch; ask?: Ask; say?: (l: string) => void; max?: number; setting?: MovesSetting } = {}): Promise<MovesRound> {
307
+ const out: MovesRound = { done: 0, refused: 0, failed: 0, pending: 0 }
308
+ const call = caller(home, o)
309
+ if (!call) return { ...out, why: "not paired" }
310
+ await resendUnsent(home, call)
311
+ let list: Move[]
312
+ try {
313
+ const r = await call("GET", `${MOVES_PATH}?machine=${encodeURIComponent(machineId(home))}`)
314
+ if (r.status !== 200 || !Array.isArray(r.json?.moves)) return { ...out, why: `the gateway answered HTTP ${r.status}` }
315
+ list = r.json.moves.map(moveFrom).filter((m: Move | undefined): m is Move => !!m)
316
+ } catch (e) {
317
+ return { ...out, why: (e as Error)?.name === "TimeoutError" ? "the gateway did not answer in time" : `could not reach ${gatewayUrl(home)}` }
318
+ }
319
+ const say = o.say ?? (() => {})
320
+ const waitingHere = new Set(readLocal(home).moves.map((p) => p.id))
321
+ for (const m of list.filter((x) => x.state === "waiting" && !waitingHere.has(x.id)).slice(0, o.max ?? 3)) {
322
+ const setting = o.setting ?? movesSetting(home)
323
+ if (setting === "off") {
324
+ await sendResult(home, call, m.id, "refused", { error: "moves from the dashboard are off on this computer (brain settings set moves.fromDashboard ask)", at: new Date().toISOString() })
325
+ out.refused++
326
+ say(`refused a move from the dashboard (moves are off here): ${m.session} to ${m.to}`)
327
+ continue
328
+ }
329
+ // Claimed before anything happens: a cancelled or expired move is dropped, and no other round takes this one again.
330
+ if ((await postState(call, m.id, "asking")) !== "ok") continue
331
+ const found = await exactSession(home, m.session)
332
+ if ("why" in found) {
333
+ await sendResult(home, call, m.id, "failed", { error: found.why, at: new Date().toISOString() })
334
+ out.failed++
335
+ continue
336
+ }
337
+ if (setting === "ask") {
338
+ const answer = (o.ask ?? askOnDesktop)(await questionFor(found.ref, m.to))
339
+ if (answer === "unavailable") {
340
+ try {
341
+ changeLocal(home, (l) => {
342
+ l.moves = [...l.moves.filter((p) => p.id !== m.id), { ...m, title: plainText(found.ref.title ?? "", 120) || null, askedAt: new Date().toISOString() }]
343
+ })
344
+ } catch {}
345
+ out.pending++
346
+ say(`a move from the dashboard waits for you here: brain moves approve ${m.id}`)
347
+ continue
348
+ }
349
+ if (answer !== "yes") {
350
+ await sendResult(home, call, m.id, "refused", { error: answer === "timeout" ? "no answer on this computer within 2 minutes" : "refused on this computer", at: new Date().toISOString() })
351
+ out.refused++
352
+ continue
353
+ }
354
+ }
355
+ const r = await runMove(home, m, found.ref)
356
+ await sendResult(home, call, m.id, r.state, r.result)
357
+ if (r.state === "done") out.done++
358
+ else out.failed++
359
+ say(`${r.state === "done" ? "moved" : "could not move"} ${m.session} to ${m.to}${r.result.error ? `: ${r.result.error}` : ""}`)
360
+ }
361
+ return out
362
+ }
363
+
364
+ /** `brain moves approve|refuse <id>`: a move waiting here (its full id, or a start of at least 4 characters that is unique). */
365
+ export async function decidePending(home: string, id: string, approve: boolean, o: { fetchImpl?: typeof fetch } = {}): Promise<{ ok: boolean; message: string }> {
366
+ // Taken out of the list under the lock before anything runs: two approvals never run one move twice.
367
+ let picked: PendingMove | string
368
+ try {
369
+ picked = changeLocal(home, (l): PendingMove | string => {
370
+ const want = id.trim()
371
+ const exact = l.moves.find((p) => p.id === want)
372
+ const matches = exact ? [exact] : want.length >= 4 ? l.moves.filter((p) => p.id.startsWith(want)) : []
373
+ if (!matches.length) return want.length < 4 && !exact ? "give the move's id (brain moves lists them)" : `no move "${want}" waits here (brain moves lists them)`
374
+ if (matches.length > 1) return `"${want}" matches ${matches.length} moves: give the whole id (brain moves lists them)`
375
+ l.moves = l.moves.filter((p) => p.id !== matches[0]!.id)
376
+ return matches[0]!
377
+ })
378
+ } catch (e) {
379
+ return { ok: false, message: `could not read the moves waiting here: ${String((e as Error)?.message ?? e).slice(0, 200)}` }
380
+ }
381
+ if (typeof picked === "string") return { ok: false, message: picked }
382
+ const m = picked
383
+ const call = caller(home, o)
384
+ if (!approve) {
385
+ await sendResult(home, call, m.id, "refused", { error: "refused on this computer", at: new Date().toISOString() })
386
+ return { ok: true, message: `refused the move of ${m.session} to ${m.to}` }
387
+ }
388
+ const found = await exactSession(home, m.session)
389
+ if ("why" in found) {
390
+ await sendResult(home, call, m.id, "failed", { error: found.why, at: new Date().toISOString() })
391
+ return { ok: false, message: `could not move ${m.session}: ${found.why}` }
392
+ }
393
+ const r = await runMove(home, m, found.ref)
394
+ await sendResult(home, call, m.id, r.state, r.result)
395
+ if (r.state !== "done") return { ok: false, message: `could not move ${m.session} to ${m.to}: ${r.result.error}` }
396
+ return { ok: true, message: `moved ${m.session} to ${m.to}${r.result.open ? `\nNext: ${r.result.open}` : ""}` }
397
+ }
398
+
399
+ /** What `brain moves`, `brain status` and the report say about them. */
400
+ export function movesState(home: string): { setting: MovesSetting; waiting: number } {
401
+ return { setting: movesSetting(home), waiting: readLocal(home).moves.length }
402
+ }