@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.
@@ -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
 
@@ -46,6 +46,7 @@ brain settings [list|get|set|unset|history|ids] brain's settings on this comp
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
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
49
50
  brain --version the version, and where this brain runs from
50
51
  brain expand <blob id> the full text of a tool output that was replaced by a digest`
51
52
 
@@ -70,39 +71,25 @@ async function reportLater() {
70
71
  }
71
72
 
72
73
  async function allRefs(tool?: string): Promise<{ refs: SessionRef[]; errors: string[] }> {
73
- const { READERS } = await adapters()
74
- const readers = tool ? READERS.filter((r) => r.tool === tool) : READERS
75
- if (tool && !readers.length) fail(`no reader for "${tool}"; readers: ${READERS.map((r) => r.tool).join(", ")}`)
76
- const errors: string[] = []
77
- const lists = await Promise.all(readers.map(async (r) => {
78
- try {
79
- return await r.list(home)
80
- } catch (e) {
81
- errors.push(`${r.tool}: ${(e as Error).message}`)
82
- return []
83
- }
84
- }))
85
- 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)
86
81
  }
87
82
 
88
83
  async function resolveRef(given: string): Promise<SessionRef> {
89
- // An id copied from an older, shortened list ends in "…": what comes before it is still a unique start.
90
- const id = given.replace(/…$/, "")
91
- const { readerFor } = await adapters()
92
- const colon = id.indexOf(":")
93
- const tool = colon > 0 && readerFor(id.slice(0, colon)) ? id.slice(0, colon) : undefined
94
- const want = tool ? id.slice(colon + 1) : id
95
- const { refs } = await allRefs(tool)
96
- const exact = refs.filter((r) => r.sourceId === want)
97
- const matches = exact.length ? exact : refs.filter((r) => r.sourceId.startsWith(want))
98
- if (!matches.length) fail(`no session "${id}"; see \`brain session list\``)
99
- 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`)
100
- 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
101
88
  }
102
89
 
103
90
  async function readRef(ref: SessionRef): Promise<Session> {
104
- const { readerFor } = await adapters()
105
- return readerFor(ref.tool)!.read(ref, home)
91
+ const { readSession } = await import("./session/ops.ts")
92
+ return readSession(home, ref)
106
93
  }
107
94
 
108
95
  /**
@@ -155,46 +142,26 @@ function report(r: WriteResult) {
155
142
  }
156
143
 
157
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 }) {
158
- const { writersFor } = await adapters()
159
- const candidates = writersFor(target).filter((w) => !opts.way || w.way === opts.way)
160
- if (!candidates.length) fail(`can't continue in "${target}"${opts.way ? ` by ${opts.way}` : ""}; see \`brain session targets\``)
161
- const cwd = resolve(opts.cwd ?? source.header.project.cwd ?? process.cwd())
162
- 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 } : {}) })
163
- const session = prepared.session
164
- for (const n of prepared.notes) out(n)
165
- const wopts = { home, cwd, dryRun: opts.dryRun, brief: prepared.brief }
166
- const skipped: string[] = []
167
- for (const w of candidates) {
168
- const ready = await w.ready(wopts)
169
- if (!ready.ok) {
170
- skipped.push(`${w.way}: ${ready.why}`)
171
- continue
172
- }
173
- for (const s of skipped) out(`(${s}; using the next way)`)
174
- const r = await w.write(session, wopts)
175
- report(r)
176
- if (!r.dryRun) {
177
- // The dashboard's conversion log: tools, way, title, project folder name and sizes; never the chat (src/dashboard/conversions.ts).
178
- const { logConversion } = await import("./dashboard/conversions.ts")
179
- const { readerFor } = await adapters()
180
- const to = r.tool === "cursor" ? "cursor-ide" : r.tool
181
- 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 })
182
- }
183
- if (!r.dryRun && r.way === "brief") {
184
- const brief = Bun.file(r.written[0]!)
185
- // The clipboard where the system has one (pbcopy on macOS, wl-copy or xclip on Linux, clip on Windows); a missing tool is fine.
186
- const copy = process.platform === "darwin" ? ["pbcopy"] : process.platform === "win32" ? ["clip"] : Bun.which("wl-copy") ? ["wl-copy"] : ["xclip", "-selection", "clipboard"]
187
- let copied = false
188
- try {
189
- if (await brief.exists()) copied = Bun.spawnSync(copy, { stdin: Buffer.from(await brief.text()) }).exitCode === 0
190
- } catch {}
191
- if (copied) out(" (the brief is also on the clipboard)")
192
- }
193
- if (!r.dryRun && opts.open && target === "cursor") Bun.spawnSync(["/bin/sh", "-c", r.open])
194
- if (!r.dryRun) await reportLater()
195
- 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)")
196
162
  }
197
- 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()
198
165
  }
199
166
 
200
167
  /** --lean / --full into the option (neither: decided by the session's size). */
@@ -306,6 +273,25 @@ async function main() {
306
273
  if (code === 0 && (rest[0] === "login" || (rest[0] === "logout" && was))) await reportLater()
307
274
  process.exit(code)
308
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
+ }
309
295
  if (cmd === "expand") {
310
296
  const id = rest[0] ?? fail("expand needs a blob id (from a digest: \"id=...\")")
311
297
  const { openBlobStore } = await import("./memory/blobs.ts")
@@ -323,7 +309,7 @@ async function main() {
323
309
  options: {
324
310
  tool: { type: "string" }, here: { type: "boolean" }, limit: { type: "string" }, json: { type: "boolean" },
325
311
  in: { type: "string" }, way: { type: "string" }, cwd: { type: "string" }, from: { type: "string" },
326
- "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" },
327
313
  },
328
314
  })
329
315
  if (!sub || values.help) return out(USAGE)
@@ -366,6 +352,20 @@ async function main() {
366
352
  out(`Handing off ${ref!.tool}:${ref!.sourceId}${ref!.title ? ` ("${cut(ref!.title, 60)}")` : ""}, ${ago(ref!.updatedAt)}.`)
367
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 } : {}) })
368
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
+ }
369
369
  if (sub === "targets") {
370
370
  const { READERS, WRITERS, writersFor } = await adapters()
371
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 } : {}) }) } : {}),
@@ -49,12 +49,23 @@ export interface BrainReport {
49
49
  contextEdit: boolean
50
50
  connected: Array<{ tool: string; label: string; since: string | null }>
51
51
  }
52
- memory: { available: true; projects: number; items: number } | { available: false }
52
+ memory: { available: true; projects: number; items: number; projectList?: Array<{ name: string; items: number }> } | { available: false }
53
53
  conversions: Conversion[]
54
54
  /** byMonth: conversions per month, UTC "YYYY-MM", the latest twelve months that have any. */
55
55
  totals: { conversions: number; byMonth: Record<string, number> }
56
56
  /** Every setting on this computer and its state (docs/settings-contract.md, section 3). Absent when it could not be read. */
57
57
  settings?: SettingsSection
58
+ // docs/sessions-contract.md, section 3: what the dashboard needs to show every feature. Each is optional.
59
+ /** Where a session can go from this computer, as `brain session targets` says. */
60
+ targets?: Array<{ tool: string; label: string; ways: Array<{ way: string; ready: boolean; why?: string }> }>
61
+ /** How often each kind of digested tool output is asked back (`brain tokens lookups`). */
62
+ tokens?: { lookups: Array<{ kind: string; stubbed: number; expanded: number }> }
63
+ /** Team memory (`brain org`): the server's host only, never its token. */
64
+ org?: { joined: true; server: string; scope: string | null } | { joined: false }
65
+ /** What was last sent of the session list (sessions.ts). */
66
+ sessions?: { total: number; uploadedAt: string | null; off: boolean }
67
+ /** Moves from the dashboard (moves.ts): this computer's setting, and how many wait here for the person. */
68
+ moves?: { setting: string; waiting: number }
58
69
  }
59
70
 
60
71
  export interface SettingsRow {
@@ -235,7 +246,10 @@ async function memorySummary(home: string, env: Env): Promise<BrainReport["memor
235
246
  const s = await mem.status()
236
247
  if (!s.available) return { available: false as const }
237
248
  const projects = await mem.projects()
238
- return { available: true as const, projects: projects.length, items: projects.reduce((n, p) => n + (Number(p.items) || 0), 0) }
249
+ // A project's name as memory labels it; a path in it is written from the home folder (~).
250
+ const name = (l: string) => clip(String(l ?? "").replaceAll(home, "~").replace(/[\u0000-\u001f\u007f]/g, " ").trim(), 80) || "(unnamed)"
251
+ const projectList = [...projects].sort((a, b) => (Number(b.items) || 0) - (Number(a.items) || 0)).slice(0, 50).map((p) => ({ name: name(p.label), items: Math.max(0, Math.floor(Number(p.items) || 0)) }))
252
+ return { available: true as const, projects: projects.length, items: projects.reduce((n, p) => n + (Number(p.items) || 0), 0), projectList }
239
253
  })(),
240
254
  5_000,
241
255
  )
@@ -303,6 +317,11 @@ export async function harnesses(home: string, counts: Record<string, ToolCount>
303
317
  }
304
318
  for (const t of MCP_TOOLS) add(mcpToolId(t.id), t.label, mcpToolHere(t, home, env) || mcpInstalled.get(mcpToolId(t.id)) === true)
305
319
  for (const id of HOOK_TOOLS) add(id, toolLabel(id), hookOn(id))
320
+ // The Codex app (its threads share ~/.codex with the CLI).
321
+ try {
322
+ const { codexAppHere } = await import("../adapters/codex/records.ts")
323
+ add("codex-desktop", "Codex Desktop", codexAppHere(home))
324
+ } catch {}
306
325
  const mcpIds = new Set(MCP_TOOLS.map((t) => mcpToolId(t.id)))
307
326
  const connectorIds = new Set(CONNECTORS.map((c) => c.id))
308
327
  return [...known.entries()]
@@ -312,9 +331,11 @@ export async function harnesses(home: string, counts: Record<string, ToolCount>
312
331
  label: clip(k.label, 80),
313
332
  sessions: counts?.[id] ? counts[id]!.sessions : null,
314
333
  lastSessionAt: counts?.[id]?.lastSessionAt ?? null,
315
- gateway: connected[id] ? "connected" : (ownClient(id, home, env) ?? (connectorIds.has(id) ? "available" : "unsupported")),
316
- mcp: mcpIds.has(id) ? mcpInstalled.get(id) === true : null,
317
- hooks: HOOK_TOOLS.includes(id) ? hookOn(id) : null,
334
+ // The Codex app reads Codex's own settings (~/.codex): its gateway and MCP tools are Codex's.
335
+ gateway: id === "codex-desktop" ? (connected.codex ? "connected" : "available") : connected[id] ? "connected" : (ownClient(id, home, env) ?? (connectorIds.has(id) ? "available" : "unsupported")),
336
+ mcp: id === "codex-desktop" ? mcpInstalled.get("codex") === true : mcpIds.has(id) ? mcpInstalled.get(id) === true : null,
337
+ // Claude Desktop's Code tab runs Claude Code's hooks.
338
+ hooks: HOOK_TOOLS.includes(id) ? hookOn(id) : id === "claude-desktop" ? hookOn("claude-code") : id === "codex-desktop" && HOOK_TOOLS.includes("codex") ? hookOn("codex") : null,
318
339
  }))
319
340
  .sort((a, b) => a.label.localeCompare(b.label) || a.id.localeCompare(b.id))
320
341
  }
@@ -353,9 +374,72 @@ export async function buildReport(home: string, o: { counts?: Record<string, Too
353
374
  conversions: list,
354
375
  totals: { conversions: total, byMonth },
355
376
  ...(settings ? { settings } : {}),
377
+ ...(await featureParts(home)),
356
378
  }
357
379
  }
358
380
 
381
+ /** Where a session can go from this computer: each tool brain writes, and whether each way is ready now (dry run). */
382
+ export async function targetsPart(home: string): Promise<BrainReport["targets"]> {
383
+ const { WRITERS, readerFor } = await import("../adapters/index.ts")
384
+ const { cleanText } = await import("../settings/catalog.ts")
385
+ const byTool = new Map<string, { tool: string; label: string; ways: Array<{ way: string; ready: boolean; why?: string }> }>()
386
+ await Promise.all(
387
+ WRITERS.map(async (w) => {
388
+ let ready = false
389
+ let why: string | undefined
390
+ try {
391
+ const r = await w.ready({ home, cwd: home, dryRun: true })
392
+ ready = r.ok
393
+ if (!r.ok) why = clip(cleanText(r.why, home), 200)
394
+ } catch (e) {
395
+ why = clip(cleanText(String((e as Error)?.message ?? e), home), 200)
396
+ }
397
+ const t = byTool.get(w.tool) ?? { tool: w.tool, label: clip(readerFor(w.tool === "cursor" ? "cursor-ide" : w.tool)?.label ?? w.label ?? w.tool, 60), ways: [] }
398
+ t.ways.push({ way: w.way, ready, ...(why ? { why } : {}) })
399
+ byTool.set(w.tool, t)
400
+ }),
401
+ )
402
+ const order = new Map(WRITERS.map((w, i) => [w.tool, i]))
403
+ return [...byTool.values()].sort((a, b) => (order.get(a.tool) ?? 0) - (order.get(b.tool) ?? 0)).slice(0, 60)
404
+ }
405
+
406
+ /** Works out where a session can go from here now and keeps it for the reports that follow (explicit commands only). */
407
+ export async function refreshTargets(home: string, timeoutMs = 15_000): Promise<void> {
408
+ const list = await withTimeout(targetsPart(home), timeoutMs)
409
+ if (list?.length) updateCache(home, (c) => {
410
+ c.targets = { at: new Date().toISOString(), list }
411
+ })
412
+ }
413
+
414
+ /** The additions the dashboard's every-feature panel reads (docs/sessions-contract.md, section 3); each one alone may fail. */
415
+ async function featureParts(home: string): Promise<Pick<BrainReport, "targets" | "tokens" | "org" | "sessions" | "moves">> {
416
+ const out: Pick<BrainReport, "targets" | "tokens" | "org" | "sessions" | "moves"> = {}
417
+ // From the last `brain dashboard` / `brain sync`: a writer's check may run its tool (`--version`), never in the background.
418
+ const targets = readCache(home).targets?.list
419
+ if (Array.isArray(targets) && targets.length) out.targets = targets.slice(0, 60)
420
+ try {
421
+ const { lookupRates } = await import("../memory/lookups.ts")
422
+ out.tokens = { lookups: lookupRates(home).slice(0, 30).map((r) => ({ kind: clip(r.kind, 40), stubbed: r.stubbed, expanded: r.expanded })) }
423
+ } catch {}
424
+ try {
425
+ const cfg = readOrgConfig(home)
426
+ let host = ""
427
+ try {
428
+ host = cfg ? new URL(cfg.server).host : ""
429
+ } catch {}
430
+ out.org = cfg && host ? { joined: true, server: clip(host, 120), scope: typeof cfg.defaultScope === "string" ? clip(cfg.defaultScope, 20) : null } : { joined: false }
431
+ } catch {}
432
+ try {
433
+ const { sessionsState } = await import("./sessions.ts")
434
+ out.sessions = sessionsState(home)
435
+ } catch {}
436
+ try {
437
+ const { movesState } = await import("../moves.ts")
438
+ out.moves = movesState(home)
439
+ } catch {}
440
+ return out
441
+ }
442
+
359
443
  /** The report as it is sent: at most MAX_BYTES of JSON, the oldest conversions dropped first if it is bigger. */
360
444
  export function reportBody(r: BrainReport, maxBytes = MAX_BYTES): string {
361
445
  let body = JSON.stringify(r)