@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
|
@@ -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
|
-
|
|
699
|
-
|
|
700
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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]
|
package/src/adapters/index.ts
CHANGED
|
@@ -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
|
-
|
|
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 {
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
|
|
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 {
|
|
105
|
-
return
|
|
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 {
|
|
159
|
-
const
|
|
160
|
-
|
|
161
|
-
const
|
|
162
|
-
|
|
163
|
-
const
|
|
164
|
-
for (const
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
|
|
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))]
|
package/src/dashboard/cli.ts
CHANGED
|
@@ -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: {
|
|
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 } : {}) }) } : {}),
|
package/src/dashboard/report.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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)
|