@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.
- package/README.md +41 -1
- package/package.json +1 -1
- package/src/adapters/amazon-q-ide/writer.ts +3 -1
- package/src/adapters/antigravity/reader.ts +25 -7
- package/src/adapters/claude-code/reader.ts +4 -0
- package/src/adapters/claude-desktop/index.ts +8 -0
- package/src/adapters/claude-desktop/reader.ts +140 -0
- package/src/adapters/claude-desktop/records.ts +191 -0
- package/src/adapters/claude-desktop/writer.ts +112 -0
- package/src/adapters/codex/reader.ts +19 -5
- package/src/adapters/codex/records.ts +17 -0
- package/src/adapters/codex/writer.ts +20 -7
- package/src/adapters/codex-desktop/index.ts +38 -0
- package/src/adapters/index.ts +4 -0
- package/src/cli.ts +79 -71
- package/src/dashboard/cli.ts +17 -2
- package/src/dashboard/report.ts +98 -5
- package/src/dashboard/sessions.ts +127 -0
- package/src/dashboard/state.ts +64 -5
- package/src/gateway/common.ts +8 -0
- package/src/gateway/connect.ts +33 -3
- package/src/gateway/connectors/antigravity.ts +205 -5
- package/src/gateway/connectors/claude-desktop.ts +169 -0
- package/src/gateway/connectors/index.ts +2 -0
- package/src/handoff.ts +18 -2
- package/src/hooks.ts +2 -2
- package/src/lock.ts +3 -0
- package/src/memory/mcp-install.ts +9 -0
- package/src/memory/sync.ts +25 -3
- package/src/moves.ts +402 -0
- package/src/service.ts +490 -0
- package/src/session/open.ts +82 -0
- package/src/session/ops.ts +111 -0
- package/src/settings/account.ts +65 -10
- package/src/settings/catalog.ts +20 -2
- package/src/settings/cli.ts +18 -3
- package/src/settings/env.ts +2 -0
- package/src/settings/pair.ts +3 -0
- 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("&", "&").replaceAll("<", "<")
|
|
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
|
+
}
|