@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
@@ -7,7 +7,7 @@ import { clip, header, iso, lossCounter, noteModel, toolKindOf } from "../../ses
7
7
  import { withSnapshot } from "../../sqlite.ts"
8
8
  import type { Reader, SessionRef } from "../types.ts"
9
9
  import { summaryBody } from "./native.ts"
10
- import { IMPORT_MARKER, LABEL, TOOL, idFromName, isContext, isImageLabel, isRolloutName, outputText, patchFiles } from "./records.ts"
10
+ import { IMPORT_MARKER, LABEL, TOOL, idFromName, isContext, isImageLabel, isRolloutName, outputText, patchFiles, isDesktopOriginator } from "./records.ts"
11
11
 
12
12
  // Codex CLI sessions. Each thread is a rollout under ~/.codex/sessions/YYYY/MM/DD (archived ones under
13
13
  // archived_sessions/), one JSON record per line: {timestamp, ordinal?, type, payload}, starting with
@@ -658,6 +658,7 @@ function listOne(path: string, archived: boolean, index: Map<string, ThreadRow>)
658
658
  let id = idFromName(path)
659
659
  let cwd: string | undefined
660
660
  let version: string | undefined
661
+ let originator: string | undefined
661
662
  let title: string | undefined
662
663
  if (format === "json") {
663
664
  // Legacy sessions are small single files and the index never had them, so read the first request here.
@@ -674,6 +675,7 @@ function listOne(path: string, archived: boolean, index: Map<string, ThreadRow>)
674
675
  id = str(meta.payload?.id) ?? id
675
676
  cwd = str(meta.payload?.cwd)
676
677
  version = str(meta.payload?.cli_version)
678
+ originator = str(meta.payload?.originator)
677
679
  }
678
680
  } catch {
679
681
  // Listed from its file name; read() says what is wrong with it.
@@ -691,14 +693,16 @@ function listOne(path: string, archived: boolean, index: Map<string, ThreadRow>)
691
693
  ...(title ? { title } : {}),
692
694
  ...(cwd ? { cwd } : {}),
693
695
  ...(updatedAt ? { updatedAt } : {}),
694
- extra: { format, archived, ...(version ? { version } : {}) },
696
+ extra: { format, archived, ...(version ? { version } : {}), ...(originator ? { originator } : {}) },
695
697
  }
696
698
  }
697
699
 
698
- export const codexReader: Reader = {
699
- tool: TOOL,
700
- label: LABEL,
700
+ /** Every Codex rollout under ~/.codex (the CLI's and the desktop apps'), newest first. */
701
+ export async function listRollouts(home: string): Promise<SessionRef[]> {
702
+ return codexReaderAll.list(home)
703
+ }
701
704
 
705
+ const codexReaderAll: Pick<Reader, "list"> = {
702
706
  async list(home) {
703
707
  const roots = [
704
708
  { dir: join(codexDir(home), "sessions"), archived: false },
@@ -714,6 +718,16 @@ export const codexReader: Reader = {
714
718
  }
715
719
  return refs.sort((a, b) => (b.updatedAt ?? "").localeCompare(a.updatedAt ?? ""))
716
720
  },
721
+ }
722
+
723
+ export const codexReader: Reader = {
724
+ tool: TOOL,
725
+ label: LABEL,
726
+
727
+ /** The CLI's (and editors') sessions: the desktop apps' are listed as Codex Desktop's (codex-desktop). */
728
+ async list(home) {
729
+ return (await listRollouts(home)).filter((r) => !isDesktopOriginator(r.extra?.originator))
730
+ },
717
731
 
718
732
  async read(ref) {
719
733
  const path = ref.path
@@ -1,10 +1,27 @@
1
1
  // Facts about Codex rollout records shared by the reader and the writer. Checked against codex-rs at
2
2
  // rust-v0.147.0 (the version installed here) and the rollouts it wrote on this Mac; see
3
3
  // docs/sessions/2026-11-08-session-formats.md, entries 3 and 69.
4
+ import { existsSync } from "node:fs"
4
5
 
5
6
  export const TOOL = "codex"
6
7
  export const LABEL = "Codex"
7
8
 
9
+ /**
10
+ * Codex's desktop apps start their sessions through Codex's app-server, which stamps each with the app's name as its
11
+ * originator (Codex's own source: a first-party client's name starts with "Codex ", e.g. "Codex Desktop"; the ChatGPT
12
+ * app's is "codex_chatgpt_desktop", the work app's "codex_work_desktop"). Their sessions share ~/.codex with the CLI;
13
+ * brain lists them as Codex Desktop's. Case matters: Codex's own test client ("codex-app-server-client-test") is not an app.
14
+ */
15
+ const DESKTOP_IDS = new Set(["codex_desktop", "codex_chatgpt_desktop", "codex_work_desktop"])
16
+ export const isDesktopOriginator = (o: unknown): boolean => typeof o === "string" && (/^Codex .*\b(Desktop|App)\b/.test(o) || DESKTOP_IDS.has(o))
17
+ export const DESKTOP_TOOL = "codex-desktop"
18
+ export const DESKTOP_LABEL = "Codex Desktop"
19
+ /** The Codex app is on this computer (macOS: Codex.app). */
20
+ export function codexAppHere(home: string): boolean {
21
+ if (process.platform !== "darwin") return false
22
+ return existsSync("/Applications/Codex.app") || existsSync(`${home}/Applications/Codex.app`)
23
+ }
24
+
8
25
  /** Codex versions whose rollout format the writer was checked against. */
9
26
  export const TESTED_VERSIONS = ["0.147.0"]
10
27
 
@@ -88,7 +88,7 @@ export interface RolloutPlan {
88
88
  /** The rollout lines for `session` as a new Codex thread `id`. Pure: writes nothing. */
89
89
  export function planRollout(
90
90
  session: Session,
91
- opts: { id: string; now: Date; cwd: string; cliVersion: string; modelProvider: string; git?: Omit<Project, "cwd">; newId?: () => string },
91
+ opts: { id: string; now: Date; cwd: string; cliVersion: string; modelProvider: string; git?: Omit<Project, "cwd">; newId?: () => string; originator?: string },
92
92
  ): RolloutPlan {
93
93
  const newId = opts.newId ?? (() => Bun.randomUUIDv7())
94
94
  const lines: RolloutPlan["lines"] = []
@@ -118,7 +118,7 @@ export function planRollout(
118
118
  session_id: opts.id,
119
119
  timestamp: opts.now.toISOString(),
120
120
  cwd,
121
- originator: "codex-tui",
121
+ originator: opts.originator ?? "codex-tui",
122
122
  cli_version: opts.cliVersion,
123
123
  source: "cli",
124
124
  thread_source: "user",
@@ -414,13 +414,24 @@ export type Register = (codexHome: string, threadId: string, cwd: string, name?:
414
414
  /** The title the thread gets in Codex's picker: the session's own, marked with where it came from. */
415
415
  export const threadName = (session: Session) => `${plainTitle(session.header.title ?? firstUserText(session) ?? "Session")} (from ${toolLabel(session.header.tool)})`
416
416
 
417
- export function makeCodexWriter(register: Register = registerThread): Writer {
417
+ /** For a desktop app's thread: who it is (originator), whether the app is here, and how the app opens it. */
418
+ export interface CodexKind {
419
+ tool: string
420
+ label: string
421
+ originator: string
422
+ here(home: string): boolean
423
+ notHere: string
424
+ open(id: string, cwd: string, title: string): { open: string; note?: string }
425
+ }
426
+
427
+ export function makeCodexWriter(register: Register = registerThread, kind?: CodexKind): Writer {
418
428
  return {
419
- tool: TOOL,
420
- label: LABEL,
429
+ tool: kind?.tool ?? TOOL,
430
+ label: kind?.label ?? LABEL,
421
431
  way: "native",
422
432
 
423
433
  async ready(opts: WriteOptions): Promise<Readiness> {
434
+ if (kind && !kind.here(opts.home)) return { ok: false, why: kind.notHere }
424
435
  const v = codexVersion()
425
436
  if (!v) return { ok: false, why: "Codex is not installed here (`codex --version` failed)" }
426
437
  if (!TESTED_VERSIONS.includes(v))
@@ -440,6 +451,7 @@ export function makeCodexWriter(register: Register = registerThread): Writer {
440
451
  cliVersion: codexVersion() ?? TESTED_VERSIONS[0]!,
441
452
  modelProvider: configuredProvider(opts.home),
442
453
  git: gitInfo(cwd),
454
+ ...(kind ? { originator: kind.originator } : {}),
443
455
  })
444
456
  const path = rolloutPath(opts.home, id, now)
445
457
  const lost = [...plan.lost]
@@ -463,12 +475,13 @@ export function makeCodexWriter(register: Register = registerThread): Writer {
463
475
  if (!registered.ok) lost.push(`not in Codex's thread index (${registered.why}); it resumes by id, and appears in \`codex resume\` once opened`)
464
476
  else if (registered.named === false) lost.push("thread title (Codex refused the name; the picker shows the first message instead)")
465
477
  }
478
+ const opened = kind ? kind.open(id, cwd, threadName(session)) : { open: `cd ${shellQuote(cwd)} && codex resume ${id}` }
466
479
  return {
467
- tool: TOOL,
480
+ tool: kind?.tool ?? TOOL,
468
481
  way: "native",
469
482
  sessionId: id,
470
483
  written: [path],
471
- open: `cd ${shellQuote(cwd)} && codex resume ${id}`,
484
+ ...opened,
472
485
  lost,
473
486
  dryRun: !!opts.dryRun,
474
487
  }
@@ -0,0 +1,38 @@
1
+ // Codex Desktop (OpenAI's Codex app): its threads are Codex rollouts under ~/.codex, shared with the CLI, that the app's
2
+ // app-server stamped with the app's name as originator (codex/records.ts). Listed and read as the app's; continued into
3
+ // the app as a native rollout stamped "Codex Desktop" (when the app is here and Codex's format is one brain knows), else
4
+ // by the continuation brief. The app opens on its own threads.
5
+ import { codexReader, listRollouts } from "../codex/reader.ts"
6
+ import { codexAppHere, DESKTOP_LABEL, DESKTOP_TOOL, isDesktopOriginator } from "../codex/records.ts"
7
+ import { makeCodexWriter } from "../codex/writer.ts"
8
+ import { shellQuote } from "../../session/open.ts"
9
+ import type { Reader, Writer } from "../types.ts"
10
+
11
+ export const codexDesktopReader: Reader = {
12
+ tool: DESKTOP_TOOL,
13
+ label: DESKTOP_LABEL,
14
+ async list(home) {
15
+ return (await listRollouts(home)).filter((r) => isDesktopOriginator(r.extra?.originator)).map((r) => ({ ...r, tool: DESKTOP_TOOL }))
16
+ },
17
+ async read(ref, home) {
18
+ const s = await codexReader.read({ ...ref, tool: "codex" }, home)
19
+ s.header.tool = DESKTOP_TOOL
20
+ s.header.id = `${DESKTOP_TOOL}:${ref.sourceId}`
21
+ return s
22
+ },
23
+ }
24
+
25
+ export const codexDesktopWriter = makeCodexWriter(undefined, {
26
+ tool: DESKTOP_TOOL,
27
+ label: DESKTOP_LABEL,
28
+ originator: "Codex Desktop",
29
+ here: codexAppHere,
30
+ notHere: "the Codex app is not on this computer",
31
+ open: (id, cwd, title) => ({
32
+ open: process.platform === "darwin" ? "open -a Codex" : "the Codex app",
33
+ note: `then pick "${title.slice(0, 80)}" in its threads (or in a terminal: cd ${shellQuote(cwd)} && codex resume ${id})`,
34
+ }),
35
+ })
36
+
37
+ export const readers: Reader[] = [codexDesktopReader]
38
+ export const writers: Writer[] = [codexDesktopWriter]
@@ -1,6 +1,8 @@
1
1
  import type { Reader, Writer } from "./types.ts";
2
2
  import * as claudeCode from "./claude-code/index.ts";
3
+ import * as claudeDesktop from "./claude-desktop/index.ts";
3
4
  import * as codex from "./codex/index.ts";
5
+ import * as codexDesktop from "./codex-desktop/index.ts";
4
6
  import * as cursorIde from "./cursor-ide/index.ts";
5
7
  import * as cursorCli from "./cursor-cli/index.ts";
6
8
  import * as geminiCli from "./gemini-cli/index.ts";
@@ -47,7 +49,9 @@ import { briefWriter, briefWriters } from "../brief-writers.ts";
47
49
  // no native or import writer is still a target through the continuation brief (brief-writers.ts).
48
50
  const MODULES = [
49
51
  claudeCode,
52
+ claudeDesktop,
50
53
  codex,
54
+ codexDesktop,
51
55
  cursorIde,
52
56
  cursorCli,
53
57
  geminiCli,
package/src/cli.ts CHANGED
@@ -14,10 +14,9 @@ import { countTurns, firstUserText, toJsonl, unpaired } from "./session/util.ts"
14
14
  // brain session: one working session, any AI coding tool.
15
15
  // Design: docs/sessions/2026-11-08-session-switcher.md.
16
16
 
17
- // Adapters load only for commands that use them: every tool's session-start hook runs this file, and
17
+ // Adapters load only for commands that use them (src/session/ops.ts): every tool's session-start hook runs this file, and
18
18
  // one adapter failing to load (or loading slowly) must not hold up or break a session in another tool.
19
- let loaded: typeof import("./adapters/index.ts") | undefined
20
- const adapters = async () => (loaded ??= await import("./adapters/index.ts"))
19
+ const adapters = async () => (await import("./session/ops.ts")).loadAdapters()
21
20
 
22
21
  const USAGE = `brain session <command>
23
22
 
@@ -28,6 +27,7 @@ const USAGE = `brain session <command>
28
27
  handoff --in <tool> [--from <tool>] [--dry-run] [--no-open] the newest session in this folder, to <tool>
29
28
  continue/handoff also take [--lean | --full] [--budget <tokens>] [--no-memory] [--no-tidy] lean: big tool output becomes digests (automatic on long sessions); budget: mask in levels until the session fits
30
29
  targets where a session can go, and how
30
+ open <id> [--print] open a session again in its own tool, in its own folder
31
31
  hooks install|uninstall|status [--tool <id>] [--dry-run] session-start hooks that deliver briefs
32
32
  hook <event> --tool <id> (run by a tool's hook; reads the hook's JSON on stdin)
33
33
 
@@ -45,6 +45,8 @@ brain status this computer at a glance: si
45
45
  brain settings [list|get|set|unset|history|ids] brain's settings on this computer: who set each, and whether it is applied (\`brain settings help\`)
46
46
  brain sync exchange settings with your account, apply what changed or failed, and send this computer's report now
47
47
  brain pair [--no-browser] | status | forget approve this computer in your 100xprompt account, so its settings show in the dashboard
48
+ brain service install | uninstall | status run brain in the background, so changes made in your dashboard apply here by themselves
49
+ brain moves [approve|refuse <id>] session moves asked from your dashboard that wait for you on this computer
48
50
  brain --version the version, and where this brain runs from
49
51
  brain expand <blob id> the full text of a tool output that was replaced by a digest`
50
52
 
@@ -69,39 +71,25 @@ async function reportLater() {
69
71
  }
70
72
 
71
73
  async function allRefs(tool?: string): Promise<{ refs: SessionRef[]; errors: string[] }> {
72
- const { READERS } = await adapters()
73
- const readers = tool ? READERS.filter((r) => r.tool === tool) : READERS
74
- if (tool && !readers.length) fail(`no reader for "${tool}"; readers: ${READERS.map((r) => r.tool).join(", ")}`)
75
- const errors: string[] = []
76
- const lists = await Promise.all(readers.map(async (r) => {
77
- try {
78
- return await r.list(home)
79
- } catch (e) {
80
- errors.push(`${r.tool}: ${(e as Error).message}`)
81
- return []
82
- }
83
- }))
84
- return { refs: lists.flat().sort((a, b) => (b.updatedAt ?? "").localeCompare(a.updatedAt ?? "")), errors }
74
+ const { readerFor } = await adapters()
75
+ if (tool && !readerFor(tool)) {
76
+ const { READERS } = await adapters()
77
+ fail(`no reader for "${tool}"; readers: ${READERS.map((r) => r.tool).join(", ")}`)
78
+ }
79
+ const { listSessions } = await import("./session/ops.ts")
80
+ return listSessions(home, tool)
85
81
  }
86
82
 
87
83
  async function resolveRef(given: string): Promise<SessionRef> {
88
- // An id copied from an older, shortened list ends in "…": what comes before it is still a unique start.
89
- const id = given.replace(/…$/, "")
90
- const { readerFor } = await adapters()
91
- const colon = id.indexOf(":")
92
- const tool = colon > 0 && readerFor(id.slice(0, colon)) ? id.slice(0, colon) : undefined
93
- const want = tool ? id.slice(colon + 1) : id
94
- const { refs } = await allRefs(tool)
95
- const exact = refs.filter((r) => r.sourceId === want)
96
- const matches = exact.length ? exact : refs.filter((r) => r.sourceId.startsWith(want))
97
- if (!matches.length) fail(`no session "${id}"; see \`brain session list\``)
98
- if (matches.length > 1) fail(`"${id}" matches ${matches.length} sessions (${matches.slice(0, 4).map((r) => `${r.tool}:${r.sourceId}`).join(", ")}…); give more of the id`)
99
- return matches[0]!
84
+ const { findSession } = await import("./session/ops.ts")
85
+ const r = await findSession(home, given)
86
+ if ("error" in r) fail(r.error)
87
+ return (r as { ref: SessionRef }).ref
100
88
  }
101
89
 
102
90
  async function readRef(ref: SessionRef): Promise<Session> {
103
- const { readerFor } = await adapters()
104
- return readerFor(ref.tool)!.read(ref, home)
91
+ const { readSession } = await import("./session/ops.ts")
92
+ return readSession(home, ref)
105
93
  }
106
94
 
107
95
  /**
@@ -154,46 +142,26 @@ function report(r: WriteResult) {
154
142
  }
155
143
 
156
144
  async function continueIn(source: Session, target: string, opts: { way?: Way; cwd?: string; dryRun: boolean; open: boolean; lean?: boolean; noMemory?: boolean; budget?: number; tidy?: false }) {
157
- const { writersFor } = await adapters()
158
- const candidates = writersFor(target).filter((w) => !opts.way || w.way === opts.way)
159
- if (!candidates.length) fail(`can't continue in "${target}"${opts.way ? ` by ${opts.way}` : ""}; see \`brain session targets\``)
160
- const cwd = resolve(opts.cwd ?? source.header.project.cwd ?? process.cwd())
161
- 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 } : {}) })
162
- const session = prepared.session
163
- for (const n of prepared.notes) out(n)
164
- const wopts = { home, cwd, dryRun: opts.dryRun, brief: prepared.brief }
165
- const skipped: string[] = []
166
- for (const w of candidates) {
167
- const ready = await w.ready(wopts)
168
- if (!ready.ok) {
169
- skipped.push(`${w.way}: ${ready.why}`)
170
- continue
171
- }
172
- for (const s of skipped) out(`(${s}; using the next way)`)
173
- const r = await w.write(session, wopts)
174
- report(r)
175
- if (!r.dryRun) {
176
- // The dashboard's conversion log: tools, way, title, project folder name and sizes; never the chat (src/dashboard/conversions.ts).
177
- const { logConversion } = await import("./dashboard/conversions.ts")
178
- const { readerFor } = await adapters()
179
- const to = r.tool === "cursor" ? "cursor-ide" : r.tool
180
- 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(session), lean: prepared.lean })
181
- }
182
- if (!r.dryRun && r.way === "brief") {
183
- const brief = Bun.file(r.written[0]!)
184
- // The clipboard where the system has one (pbcopy on macOS, wl-copy or xclip on Linux, clip on Windows); a missing tool is fine.
185
- const copy = process.platform === "darwin" ? ["pbcopy"] : process.platform === "win32" ? ["clip"] : Bun.which("wl-copy") ? ["wl-copy"] : ["xclip", "-selection", "clipboard"]
186
- let copied = false
187
- try {
188
- if (await brief.exists()) copied = Bun.spawnSync(copy, { stdin: Buffer.from(await brief.text()) }).exitCode === 0
189
- } catch {}
190
- if (copied) out(" (the brief is also on the clipboard)")
191
- }
192
- if (!r.dryRun && opts.open && target === "cursor") Bun.spawnSync(["/bin/sh", "-c", r.open])
193
- if (!r.dryRun) await reportLater()
194
- return
145
+ const { moveSession } = await import("./session/ops.ts")
146
+ const { open, ...moveOpts } = opts
147
+ const m = await moveSession(home, source, target, moveOpts)
148
+ for (const n of m.notes) out(n)
149
+ if (!m.ok) fail(m.why)
150
+ const r = (m as { result: WriteResult }).result
151
+ for (const s of m.skipped) out(`(${s}; using the next way)`)
152
+ report(r)
153
+ if (!r.dryRun && r.way === "brief") {
154
+ const brief = Bun.file(r.written[0]!)
155
+ // The clipboard where the system has one (pbcopy on macOS, wl-copy or xclip on Linux, clip on Windows); a missing tool is fine.
156
+ const copy = process.platform === "darwin" ? ["pbcopy"] : process.platform === "win32" ? ["clip"] : Bun.which("wl-copy") ? ["wl-copy"] : ["xclip", "-selection", "clipboard"]
157
+ let copied = false
158
+ try {
159
+ if (await brief.exists()) copied = Bun.spawnSync(copy, { stdin: Buffer.from(await brief.text()) }).exitCode === 0
160
+ } catch {}
161
+ if (copied) out(" (the brief is also on the clipboard)")
195
162
  }
196
- fail(`no way to continue in "${target}" right now: ${skipped.join("; ")}`)
163
+ if (!r.dryRun && open && target === "cursor") Bun.spawnSync(["/bin/sh", "-c", r.open])
164
+ if (!r.dryRun) await reportLater()
197
165
  }
198
166
 
199
167
  /** --lean / --full into the option (neither: decided by the session's size). */
@@ -231,7 +199,14 @@ async function main() {
231
199
  }
232
200
  if (cmd === "status") {
233
201
  const { statusCommand } = await import("./settings/cli.ts")
234
- process.exit(await statusCommand(home))
202
+ let changed = false
203
+ const code = await statusCommand(home, undefined, undefined, { changed: () => (changed = true) })
204
+ if (changed) await reportLater()
205
+ process.exit(code)
206
+ }
207
+ if (cmd === "service") {
208
+ const { serviceCommand } = await import("./service.ts")
209
+ process.exit(await serviceCommand(rest, home))
235
210
  }
236
211
  if (cmd === "pair") {
237
212
  const { pairCommand } = await import("./settings/pair.ts")
@@ -298,6 +273,25 @@ async function main() {
298
273
  if (code === 0 && (rest[0] === "login" || (rest[0] === "logout" && was))) await reportLater()
299
274
  process.exit(code)
300
275
  }
276
+ if (cmd === "moves") {
277
+ const { decidePending, movesState, readPending } = await import("./moves.ts")
278
+ const sub = rest[0]
279
+ if (sub === "approve" || sub === "refuse") {
280
+ const id = rest[1] ?? fail(`moves ${sub} needs a move id (brain moves lists them)`)
281
+ const r = await decidePending(home, id, sub === "approve")
282
+ out(r.message)
283
+ if (r.ok && sub === "approve") await reportLater()
284
+ process.exit(r.ok ? 0 : 1)
285
+ }
286
+ if (sub && sub !== "list") fail(`unknown moves command "${sub}"; usage: brain moves [approve|refuse <id>]`)
287
+ const st = movesState(home)
288
+ out(`moves from the dashboard: ${st.setting === "ask" ? "asks you first" : st.setting === "allow" ? "run without asking" : "off (refused)"} (brain settings set moves.fromDashboard ask|allow|off)`)
289
+ const p = readPending(home).moves
290
+ if (!p.length) out("nothing waits for you here")
291
+ const { plainText } = await import("./moves.ts")
292
+ for (const m of p) out(` ${m.id} ${m.session}${m.title ? ` ("${plainText(m.title, 80)}")` : ""} to ${m.to}${m.way ? ` by ${m.way}` : ""} asked ${ago(m.askedAt)}: brain moves approve ${m.id} | brain moves refuse ${m.id}`)
293
+ process.exit(0)
294
+ }
301
295
  if (cmd === "expand") {
302
296
  const id = rest[0] ?? fail("expand needs a blob id (from a digest: \"id=...\")")
303
297
  const { openBlobStore } = await import("./memory/blobs.ts")
@@ -315,7 +309,7 @@ async function main() {
315
309
  options: {
316
310
  tool: { type: "string" }, here: { type: "boolean" }, limit: { type: "string" }, json: { type: "boolean" },
317
311
  in: { type: "string" }, way: { type: "string" }, cwd: { type: "string" }, from: { type: "string" },
318
- "dry-run": { type: "boolean" }, "no-open": { type: "boolean" }, lean: { type: "boolean" }, full: { type: "boolean" }, "no-memory": { type: "boolean" }, budget: { type: "string" }, "no-tidy": { type: "boolean" }, o: { type: "string" }, help: { type: "boolean", short: "h" },
312
+ "dry-run": { type: "boolean" }, "no-open": { type: "boolean" }, print: { type: "boolean" }, lean: { type: "boolean" }, full: { type: "boolean" }, "no-memory": { type: "boolean" }, budget: { type: "string" }, "no-tidy": { type: "boolean" }, o: { type: "string" }, help: { type: "boolean", short: "h" },
319
313
  },
320
314
  })
321
315
  if (!sub || values.help) return out(USAGE)
@@ -358,6 +352,20 @@ async function main() {
358
352
  out(`Handing off ${ref!.tool}:${ref!.sourceId}${ref!.title ? ` ("${cut(ref!.title, 60)}")` : ""}, ${ago(ref!.updatedAt)}.`)
359
353
  return continueIn(await readRef(ref!), target, { ...(values.way ? { way: values.way as Way } : {}), cwd: here, dryRun: !!values["dry-run"], open: !values["no-open"], ...leanOf(values), ...budgetOf(values), ...(values["no-tidy"] ? { tidy: false as const } : {}), ...(values["no-memory"] ? { noMemory: true } : {}) })
360
354
  }
355
+ if (sub === "open") {
356
+ const id = positionals[0] ?? fail("open needs a session id")
357
+ const ref = await resolveRef(id)
358
+ const { openPlan } = await import("./session/open.ts")
359
+ const { readerFor } = await adapters()
360
+ const plan = openPlan(ref, readerFor(ref.tool)?.label ?? ref.tool, home)
361
+ if ("none" in plan) fail(plan.none)
362
+ const p = plan as { command: string; note?: string }
363
+ if (values.print) return out(p.command)
364
+ out(`Opening ${readerFor(ref.tool)?.label ?? ref.tool}${ref.title ? ` "${cut(ref.title, 60)}"` : ""}: ${p.command}`)
365
+ if (p.note) out(` ${p.note}`)
366
+ const child = Bun.spawn(["/bin/sh", "-c", p.command], { stdio: ["inherit", "inherit", "inherit"] })
367
+ process.exit(await child.exited)
368
+ }
361
369
  if (sub === "targets") {
362
370
  const { READERS, WRITERS, writersFor } = await adapters()
363
371
  const targets = [...new Set(WRITERS.map((w) => w.tool))]
@@ -59,7 +59,15 @@ export async function dashboardCommand(
59
59
  args: string[],
60
60
  home: string,
61
61
  io: { out(l: string): void; err(l: string): void } = { out: (l) => process.stdout.write(`${l}\n`), err: (l) => process.stderr.write(`${l}\n`) },
62
- deps: { env?: Env; fetchImpl?: typeof fetch; open?: (url: string) => void; countTimeoutMs?: number; readers?: Reader[] } = {},
62
+ deps: {
63
+ env?: Env
64
+ fetchImpl?: typeof fetch
65
+ open?: (url: string) => void
66
+ countTimeoutMs?: number
67
+ readers?: Reader[]
68
+ /** Where sessions can go (runs each tool's `--version`); tests give a stand-in so they do not run the machine's tools. */
69
+ refreshTargets?: (home: string) => Promise<void>
70
+ } = {},
63
71
  ): Promise<number> {
64
72
  const env = deps.env ?? process.env
65
73
  const atAt = args.indexOf("--at")
@@ -83,7 +91,14 @@ export async function dashboardCommand(
83
91
  const send = async (counts: boolean): Promise<void> => {
84
92
  // The notice goes where the person reads it: the output, or stderr when the output is asked to stay quiet.
85
93
  noticeOnce(home, quiet ? io.err : io.out)
86
- const { buildReport, countAndKeep } = await import("./report.ts")
94
+ const { buildReport, countAndKeep, refreshTargets } = await import("./report.ts")
95
+ // `brain dashboard` (the person's command): where sessions can go, and the session list, now.
96
+ if (counts) {
97
+ await (deps.refreshTargets ?? refreshTargets)(home)
98
+ const { sendSessions } = await import("./sessions.ts")
99
+ const s = await sendSessions(home, { env, force: true, ...(deps.fetchImpl ? { fetchImpl: deps.fetchImpl } : {}) })
100
+ if (s.sent) say(`sent this computer's session list (${s.total} sessions)`)
101
+ }
87
102
  const report = await buildReport(home, {
88
103
  env,
89
104
  ...(counts ? { counts: await countAndKeep(home, { timeoutMs: deps.countTimeoutMs ?? 20_000, ...(deps.readers ? { readers: deps.readers } : {}) }) } : {}),