@100xprompt/brain 0.1.5 → 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -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 +70 -70
- package/src/dashboard/cli.ts +17 -2
- package/src/dashboard/report.ts +89 -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/memory/mcp-install.ts +9 -0
- package/src/memory/sync.ts +25 -3
- package/src/moves.ts +402 -0
- package/src/service.ts +19 -2
- package/src/session/open.ts +82 -0
- package/src/session/ops.ts +111 -0
- package/src/settings/catalog.ts +20 -2
- package/src/settings/cli.ts +8 -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
|
+
}
|
package/src/service.ts
CHANGED
|
@@ -324,6 +324,8 @@ export function refreshEnv(home: string, env: Env = process.env) {
|
|
|
324
324
|
export interface LoopOptions {
|
|
325
325
|
/** Stop after this many rounds (tests). */
|
|
326
326
|
rounds?: number
|
|
327
|
+
/** How the person is asked about a move from the dashboard (tests; else a dialog, moves.ts). */
|
|
328
|
+
ask?: import("./moves.ts").Ask
|
|
327
329
|
env?: Env
|
|
328
330
|
fetchImpl?: typeof fetch
|
|
329
331
|
say?: (line: string) => void
|
|
@@ -415,8 +417,23 @@ export async function runService(home: string, o: LoopOptions = {}): Promise<"st
|
|
|
415
417
|
}
|
|
416
418
|
// Written before the report: the report says this loop runs.
|
|
417
419
|
keepTurn(home, state, loopEvery(env))
|
|
418
|
-
|
|
419
|
-
|
|
420
|
+
let moved = 0
|
|
421
|
+
if (x.ok) {
|
|
422
|
+
// Moves asked from the dashboard (moves.ts, docs/sessions-contract.md): as moves.fromDashboard says (ask first).
|
|
423
|
+
try {
|
|
424
|
+
const { processMoves } = await import("./moves.ts")
|
|
425
|
+
const mv = await processMoves(home, { ...(o.fetchImpl ? { fetchImpl: o.fetchImpl } : {}), ...(o.ask ? { ask: o.ask } : {}), say })
|
|
426
|
+
moved = mv.done + mv.refused + mv.failed + mv.pending
|
|
427
|
+
} catch {}
|
|
428
|
+
// The session list when it changed (looked at no more than every 5 minutes), and at once after a move.
|
|
429
|
+
try {
|
|
430
|
+
const { sendSessions } = await import("./dashboard/sessions.ts")
|
|
431
|
+
const sent = await sendSessions(home, { env, force: moved > 0, ...(o.fetchImpl ? { fetchImpl: o.fetchImpl } : {}) })
|
|
432
|
+
if (sent.sent) say(`sent the session list (${sent.total} sessions)`)
|
|
433
|
+
} catch {}
|
|
434
|
+
}
|
|
435
|
+
if (x.ok && (x.received.length || x.applied.length || moved)) {
|
|
436
|
+
if (x.received.length || x.applied.length) say(`took ${x.received.length} change(s) from your account, applied ${x.applied.length}`)
|
|
420
437
|
// The dashboard sees the result at once.
|
|
421
438
|
if (canReport(home) && !sendingOff(home, env)) {
|
|
422
439
|
try {
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// `brain session open <id>`: a session opened in its own tool, in its own folder: the tool's resume command, as brain's
|
|
2
|
+
// writers print it after a move (src/adapters/*/writer.ts). The dashboard shows `brain session open <id>` for every
|
|
3
|
+
// session, so it needs no path and no tool's own syntax (docs/sessions-contract.md, section 3).
|
|
4
|
+
import { existsSync } from "node:fs"
|
|
5
|
+
import type { SessionRef } from "../adapters/types.ts"
|
|
6
|
+
import { LAUNCHER_NAME, launcherApp } from "../gateway/connectors/antigravity.ts"
|
|
7
|
+
|
|
8
|
+
/** A word for sh: as it is when it holds nothing the shell reads, else in single quotes (as the writers quote). */
|
|
9
|
+
export const shellQuote = (s: string) => (/^[\w./@%+=:,-]+$/.test(s) ? s : `'${s.replace(/'/g, `'\\''`)}'`)
|
|
10
|
+
|
|
11
|
+
type Resume = (id: string, cwd: string) => string
|
|
12
|
+
|
|
13
|
+
/** Terminal tools, by brain's reader id: the command that reopens session `id` (run in the session's folder). */
|
|
14
|
+
const IN_FOLDER: Record<string, Resume> = {
|
|
15
|
+
"claude-code": (id) => `claude --resume ${shellQuote(id)}`,
|
|
16
|
+
codex: (id) => `codex resume ${shellQuote(id)}`,
|
|
17
|
+
"gemini-cli": (id) => `gemini --resume ${shellQuote(id)}`,
|
|
18
|
+
"copilot-cli": (id) => `copilot --resume ${shellQuote(id)}`,
|
|
19
|
+
opencode: (id) => `opencode --session ${shellQuote(id)}`,
|
|
20
|
+
aider: () => "aider --restore-chat-history",
|
|
21
|
+
"amazon-q-cli": () => "q chat --resume",
|
|
22
|
+
codebuff: (id) => `codebuff --continue ${shellQuote(id)}`,
|
|
23
|
+
crush: (id) => `crush --session ${shellQuote(id)}`,
|
|
24
|
+
"factory-droid": (id) => `droid --resume ${shellQuote(id)}`,
|
|
25
|
+
goose: (id) => `goose session --resume --session-id ${shellQuote(id)} --history`,
|
|
26
|
+
"grok-cli": (id) => `grok --resume ${shellQuote(id)}`,
|
|
27
|
+
"iflow-cli": (id) => `iflow --resume ${shellQuote(id)}`,
|
|
28
|
+
"kimi-code": (id) => `kimi --session ${shellQuote(id)}`,
|
|
29
|
+
"mistral-vibe": (id) => `vibe --resume ${shellQuote(id)}`,
|
|
30
|
+
openhands: (id) => `openhands --resume ${shellQuote(id)}`,
|
|
31
|
+
"qoder-cli": (id) => `qodercli --resume ${shellQuote(id)}`,
|
|
32
|
+
"qwen-code": (id) => `qwen --resume ${shellQuote(id)}`,
|
|
33
|
+
"tabnine-cli": (id) => `tabnine --resume ${shellQuote(id)}`,
|
|
34
|
+
antigravity: (id) => `agy --conversation ${shellQuote(id)}`,
|
|
35
|
+
devin: (id) => `devin --resume ${shellQuote(id)}`,
|
|
36
|
+
omp: (id) => `omp --resume ${shellQuote(id)}`,
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Desktop apps: opened on the session's folder, where the person picks the session from the app's history. */
|
|
40
|
+
const APPS: Record<string, (cwd: string) => string | undefined> = {
|
|
41
|
+
// The Claude app opens on its own sessions (the Code tab): no folder to give it.
|
|
42
|
+
"claude-desktop": () => (process.platform === "darwin" ? "open -a Claude" : undefined),
|
|
43
|
+
"codex-desktop": () => (process.platform === "darwin" ? "open -a Codex" : undefined),
|
|
44
|
+
"cursor-ide": (cwd) => (process.platform === "darwin" ? `open -a Cursor ${shellQuote(cwd)}` : `cursor ${shellQuote(cwd)}`),
|
|
45
|
+
"vscode-copilot": (cwd) => `code ${shellQuote(cwd)}`,
|
|
46
|
+
zed: (cwd) => `zed ${shellQuote(cwd)}`,
|
|
47
|
+
void: (cwd) => (process.platform === "darwin" ? `open -a Void ${shellQuote(cwd)}` : undefined),
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export type OpenPlan = { command: string; note?: string } | { none: string }
|
|
51
|
+
|
|
52
|
+
/** How session `ref` is opened again where it is, or why brain cannot do that for its tool. */
|
|
53
|
+
/** Antigravity's IDE app on this computer, when there is one (macOS: its name has changed between releases). */
|
|
54
|
+
function antigravityApp(): string | undefined {
|
|
55
|
+
if (process.platform !== "darwin") return undefined
|
|
56
|
+
return ["Antigravity IDE", "Antigravity"].find((n) => existsSync(`/Applications/${n}.app`))
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function openPlan(ref: SessionRef, label: string = ref.tool, home?: string): OpenPlan {
|
|
60
|
+
const cwd = ref.cwd
|
|
61
|
+
// Antigravity on the 100xprompt gateway (brain gateway connect --tool antigravity): only its launcher starts it with the
|
|
62
|
+
// gateway's models, so the session is opened there (the CLI, started from a shell, would use Google's).
|
|
63
|
+
if (ref.tool === "antigravity" && home && process.platform === "darwin" && existsSync(launcherApp(home)))
|
|
64
|
+
return { command: `open -a ${shellQuote(LAUNCHER_NAME)}`, note: `then pick "${ref.title ?? ref.sourceId}" in its Agent Manager` }
|
|
65
|
+
// Antigravity: its CLI reopens a conversation by id; without the CLI, the IDE app opens and the person picks it.
|
|
66
|
+
if (ref.tool === "antigravity" && !Bun.which("agy")) {
|
|
67
|
+
const app = antigravityApp()
|
|
68
|
+
if (app) return { command: `open -a ${shellQuote(app)}`, note: `then pick "${ref.title ?? ref.sourceId}" in its Agent Manager` }
|
|
69
|
+
}
|
|
70
|
+
const resume = IN_FOLDER[ref.tool]
|
|
71
|
+
if (resume) {
|
|
72
|
+
const cmd = resume(ref.sourceId, cwd ?? "")
|
|
73
|
+
return cwd ? { command: `cd ${shellQuote(cwd)} && ${cmd}` } : { command: cmd, note: `run it in the session's project folder (${label} did not record it)` }
|
|
74
|
+
}
|
|
75
|
+
const app = APPS[ref.tool]
|
|
76
|
+
// Desktop apps open on their own sessions: no folder to give them.
|
|
77
|
+
const noFolder = ref.tool === "claude-desktop" || ref.tool === "codex-desktop"
|
|
78
|
+
const appCmd = app && (cwd || noFolder) ? app(cwd ?? "") : undefined
|
|
79
|
+
const where = ref.tool === "claude-desktop" ? "in the Code tab's sessions" : ref.tool === "codex-desktop" ? "in its threads" : `from ${label}'s chat history`
|
|
80
|
+
if (appCmd) return { command: appCmd, note: `then pick "${ref.title ?? ref.sourceId}" ${where}` }
|
|
81
|
+
return { none: `brain can't reopen ${label} sessions by itself: open ${label}${cwd ? ` in ${cwd}` : ""} and pick "${ref.title ?? ref.sourceId}" from its history (or move it: brain session continue ${ref.tool}:${ref.sourceId} --in <tool>)` }
|
|
82
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// Listing, finding, reading and moving sessions, for `brain session ...` and for brain's background service (a move asked
|
|
2
|
+
// from the dashboard, docs/sessions-contract.md). Nothing here prints or exits: callers say what happened.
|
|
3
|
+
import { resolve } from "node:path"
|
|
4
|
+
import type { SessionRef, Way, WriteResult } from "../adapters/types.ts"
|
|
5
|
+
import { toolLabel } from "../brief.ts"
|
|
6
|
+
import { prepare, sessionTokens } from "../memory/prepare.ts"
|
|
7
|
+
import type { Session } from "./types.ts"
|
|
8
|
+
|
|
9
|
+
// Adapters load only for what uses them: every tool's session-start hook runs cli.ts, and one adapter failing to load
|
|
10
|
+
// (or loading slowly) must not hold up or break a session in another tool.
|
|
11
|
+
let loaded: typeof import("../adapters/index.ts") | undefined
|
|
12
|
+
export const loadAdapters = async () => (loaded ??= await import("../adapters/index.ts"))
|
|
13
|
+
|
|
14
|
+
/** Every session brain can read under `home` (one tool's with `tool`), newest first, and the tools it could not list. */
|
|
15
|
+
export async function listSessions(home: string, tool?: string): Promise<{ refs: SessionRef[]; errors: string[] }> {
|
|
16
|
+
const { READERS } = await loadAdapters()
|
|
17
|
+
const readers = tool ? READERS.filter((r) => r.tool === tool) : READERS
|
|
18
|
+
const errors: string[] = []
|
|
19
|
+
const lists = await Promise.all(
|
|
20
|
+
readers.map(async (r) => {
|
|
21
|
+
try {
|
|
22
|
+
return await r.list(home)
|
|
23
|
+
} catch (e) {
|
|
24
|
+
errors.push(`${r.tool}: ${(e as Error).message}`)
|
|
25
|
+
return []
|
|
26
|
+
}
|
|
27
|
+
}),
|
|
28
|
+
)
|
|
29
|
+
return { refs: lists.flat().sort((a, b) => (b.updatedAt ?? "").localeCompare(a.updatedAt ?? "")), errors }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Whether a Claude Desktop session's transcripts (the current one or an earlier one) include Claude Code transcript `id`. */
|
|
33
|
+
export const desktopHolds = (r: SessionRef, id: string) =>
|
|
34
|
+
r.extra?.cliSessionId === id || (Array.isArray(r.extra?.priorCliSessionIds) && (r.extra.priorCliSessionIds as unknown[]).includes(id))
|
|
35
|
+
|
|
36
|
+
/** A session by `<tool>:<id>` (as `brain session list` prints it) or a unique start of its id. */
|
|
37
|
+
export async function findSession(home: string, given: string): Promise<{ ref: SessionRef } | { error: string }> {
|
|
38
|
+
// An id copied from an older, shortened list ends in "…": what comes before it is still a unique start.
|
|
39
|
+
const id = given.replace(/…$/, "")
|
|
40
|
+
const { readerFor } = await loadAdapters()
|
|
41
|
+
const colon = id.indexOf(":")
|
|
42
|
+
const tool = colon > 0 && readerFor(id.slice(0, colon)) ? id.slice(0, colon) : undefined
|
|
43
|
+
const want = tool ? id.slice(colon + 1) : id
|
|
44
|
+
if (!want) return { error: `no session "${id}"; see \`brain session list\`` }
|
|
45
|
+
const { refs } = await listSessions(home, tool)
|
|
46
|
+
const exact = refs.filter((r) => r.sourceId === want)
|
|
47
|
+
const matches = exact.length ? exact : refs.filter((r) => r.sourceId.startsWith(want))
|
|
48
|
+
if (!matches.length && (!tool || tool === "claude-code")) {
|
|
49
|
+
// A Claude Code transcript Claude Desktop keeps a record of (its current one or an earlier one) is listed as its Claude
|
|
50
|
+
// Desktop session (an id from before).
|
|
51
|
+
const desk = (await listSessions(home, "claude-desktop")).refs.find((r) => desktopHolds(r, want))
|
|
52
|
+
if (desk) return { ref: desk }
|
|
53
|
+
}
|
|
54
|
+
if (!matches.length && (!tool || tool === "codex")) {
|
|
55
|
+
// A Codex thread the Codex app started is listed as Codex Desktop's (an id from before).
|
|
56
|
+
const desk = (await listSessions(home, "codex-desktop")).refs.find((r) => r.sourceId === want)
|
|
57
|
+
if (desk) return { ref: desk }
|
|
58
|
+
}
|
|
59
|
+
if (!matches.length) return { error: `no session "${id}"; see \`brain session list\`` }
|
|
60
|
+
if (matches.length > 1) return { error: `"${id}" matches ${matches.length} sessions (${matches.slice(0, 4).map((r) => `${r.tool}:${r.sourceId}`).join(", ")}…); give more of the id` }
|
|
61
|
+
return { ref: matches[0]! }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function readSession(home: string, ref: SessionRef): Promise<Session> {
|
|
65
|
+
const { readerFor } = await loadAdapters()
|
|
66
|
+
return readerFor(ref.tool)!.read(ref, home)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface MoveOptions {
|
|
70
|
+
way?: Way
|
|
71
|
+
cwd?: string
|
|
72
|
+
dryRun: boolean
|
|
73
|
+
lean?: boolean
|
|
74
|
+
noMemory?: boolean
|
|
75
|
+
budget?: number
|
|
76
|
+
tidy?: false
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export type MoveOutcome =
|
|
80
|
+
| { ok: true; result: WriteResult; notes: string[]; skipped: string[] }
|
|
81
|
+
| { ok: false; why: string; notes: string[]; skipped: string[] }
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* `source` continued in `target`: the first of its ways that is ready (or `way`), the session prepared as `brain session
|
|
85
|
+
* continue` prepares it (lean, memory, budget), written, and the move logged for the dashboard. Opens nothing.
|
|
86
|
+
*/
|
|
87
|
+
export async function moveSession(home: string, source: Session, target: string, opts: MoveOptions): Promise<MoveOutcome> {
|
|
88
|
+
const { writersFor, readerFor } = await loadAdapters()
|
|
89
|
+
const candidates = writersFor(target).filter((w) => !opts.way || w.way === opts.way)
|
|
90
|
+
if (!candidates.length) return { ok: false, why: `can't continue in "${target}"${opts.way ? ` by ${opts.way}` : ""}; see \`brain session targets\``, notes: [], skipped: [] }
|
|
91
|
+
const cwd = resolve(opts.cwd ?? source.header.project.cwd ?? process.cwd())
|
|
92
|
+
const prepared = await prepare(source, { home, cwd, dryRun: opts.dryRun, ...(opts.lean !== undefined ? { lean: opts.lean } : {}), ...(opts.noMemory ? { noMemory: true } : {}), ...(opts.budget ? { budget: opts.budget } : {}), ...(opts.tidy === false ? { tidy: false } : {}) })
|
|
93
|
+
const wopts = { home, cwd, dryRun: opts.dryRun, brief: prepared.brief }
|
|
94
|
+
const skipped: string[] = []
|
|
95
|
+
for (const w of candidates) {
|
|
96
|
+
const ready = await w.ready(wopts)
|
|
97
|
+
if (!ready.ok) {
|
|
98
|
+
skipped.push(`${w.way}: ${ready.why}`)
|
|
99
|
+
continue
|
|
100
|
+
}
|
|
101
|
+
const r = await w.write(prepared.session, wopts)
|
|
102
|
+
if (!r.dryRun) {
|
|
103
|
+
// The dashboard's conversion log: tools, way, title, project folder name and sizes; never the chat (src/dashboard/conversions.ts).
|
|
104
|
+
const { logConversion } = await import("../dashboard/conversions.ts")
|
|
105
|
+
const to = r.tool === "cursor" ? "cursor-ide" : r.tool
|
|
106
|
+
logConversion(home, { from: source.header.tool, fromLabel: readerFor(source.header.tool)?.label ?? toolLabel(source.header.tool), to: r.tool, toLabel: readerFor(to)?.label ?? w.label, way: r.way, title: source.header.title, cwd: source.header.project.cwd ?? cwd, turns: source.turns.length, tokens: sessionTokens(prepared.session), lean: prepared.lean })
|
|
107
|
+
}
|
|
108
|
+
return { ok: true, result: r, notes: prepared.notes, skipped }
|
|
109
|
+
}
|
|
110
|
+
return { ok: false, why: `no way to continue in "${target}" right now: ${skipped.join("; ")}`, notes: prepared.notes, skipped }
|
|
111
|
+
}
|