@getmegabrain/cli 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.
@@ -38,28 +38,40 @@ function tryOpenBrowser(url) {
38
38
  }
39
39
  }
40
40
  const POLL_INTERVAL_MS = 2000;
41
- export async function login(log = console.log) {
41
+ /**
42
+ * Begin a device-auth attempt without blocking: returns the code + verification URL to show
43
+ * the user immediately, and a `completed` promise that polls until it is approved. The
44
+ * `megabrain science` daemon uses this to render the code in the browser while it waits;
45
+ * `login()` (below) is the terminal wrapper that also prints it and opens the browser.
46
+ */
47
+ export async function startDeviceAuth() {
42
48
  const { code, verificationUrl, expiresIn } = await createDeviceAuthCode();
49
+ const deadline = Date.now() + expiresIn * 1000;
50
+ const completed = (async () => {
51
+ while (Date.now() < deadline) {
52
+ await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL_MS));
53
+ const result = await pollDeviceAuthCode(code);
54
+ if (result.status === 'approved') {
55
+ return { token: result.token, userId: result.userId, userEmail: result.userEmail };
56
+ }
57
+ if (result.status === 'denied')
58
+ throw new Error('Sign-in was denied.');
59
+ if (result.status === 'expired') {
60
+ throw new Error('Sign-in code expired. Run `megabrain login` again.');
61
+ }
62
+ // 'pending' — keep polling.
63
+ }
64
+ throw new Error('Sign-in timed out. Run `megabrain login` again.');
65
+ })();
66
+ return { code, verificationUrl, expiresIn, completed };
67
+ }
68
+ export async function login(log = console.log) {
69
+ const { code, verificationUrl, completed } = await startDeviceAuth();
43
70
  log('');
44
71
  log(` Confirm this code: ${code}`);
45
72
  log(` Sign in at: ${verificationUrl}`);
46
73
  log('');
47
74
  log(' Opening your browser…');
48
75
  tryOpenBrowser(verificationUrl);
49
- const deadline = Date.now() + expiresIn * 1000;
50
- while (Date.now() < deadline) {
51
- await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL_MS));
52
- const result = await pollDeviceAuthCode(code);
53
- if (result.status === 'approved') {
54
- return { token: result.token, userId: result.userId, userEmail: result.userEmail };
55
- }
56
- if (result.status === 'denied') {
57
- throw new Error('Sign-in was denied.');
58
- }
59
- if (result.status === 'expired') {
60
- throw new Error('Sign-in code expired. Run `megabrain login` again.');
61
- }
62
- // 'pending' — keep polling.
63
- }
64
- throw new Error('Sign-in timed out. Run `megabrain login` again.');
76
+ return completed;
65
77
  }
package/dist/index.js CHANGED
@@ -8,6 +8,7 @@ import { writeOpenCodeConfig } from './opencode-config.js';
8
8
  import { fetchGatewayModelIds } from './models.js';
9
9
  import { ensureOpencodeBinary } from './opencode.js';
10
10
  import { parseCloudForkArgs, isValidSessionId, downloadSessionExport } from './cloud-fork.js';
11
+ import { runScience, runScienceUrl } from './science/index.js';
11
12
  async function signIn() {
12
13
  console.log('Signing in to MegaBrain…');
13
14
  const { token, userId, userEmail } = await login();
@@ -93,6 +94,14 @@ async function main() {
93
94
  case 'whoami':
94
95
  runWhoami();
95
96
  return;
97
+ case 'science':
98
+ if (process.argv[3] === 'url') {
99
+ await runScienceUrl();
100
+ }
101
+ else {
102
+ await runScience();
103
+ }
104
+ return;
96
105
  default: {
97
106
  const args = process.argv.slice(2);
98
107
  const { cloudFork, sessionId } = parseCloudForkArgs(args);
@@ -0,0 +1,77 @@
1
+ import { spawn } from 'node:child_process';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { MEGABRAIN_CONFIG_DIR } from '../paths.js';
5
+ import { startScienceServer } from './server.js';
6
+ /**
7
+ * `megabrain science` — start the local daemon that serves the workbench on localhost, and
8
+ * `megabrain science url` — print a fresh single-use sign-in link for the running daemon.
9
+ * See docs/megabrain-science/reference/claude-science-flow.md §0 (architecture) and §1.1.
10
+ */
11
+ const RUNTIME_FILE = path.join(MEGABRAIN_CONFIG_DIR, 'science-daemon.json');
12
+ function writeRuntime(rt) {
13
+ fs.mkdirSync(MEGABRAIN_CONFIG_DIR, { recursive: true });
14
+ fs.writeFileSync(RUNTIME_FILE, JSON.stringify(rt), { mode: 0o600 });
15
+ }
16
+ function readRuntime() {
17
+ try {
18
+ return JSON.parse(fs.readFileSync(RUNTIME_FILE, 'utf8'));
19
+ }
20
+ catch {
21
+ return null;
22
+ }
23
+ }
24
+ function openBrowser(url) {
25
+ const platform = process.platform;
26
+ const command = platform === 'darwin' ? 'open' : platform === 'win32' ? 'start' : 'xdg-open';
27
+ const args = platform === 'win32' ? ['', url] : [url];
28
+ try {
29
+ const child = spawn(command, args, {
30
+ shell: platform === 'win32',
31
+ stdio: 'ignore',
32
+ detached: true,
33
+ });
34
+ child.on('error', () => { });
35
+ child.unref();
36
+ }
37
+ catch {
38
+ // The printed URL is the fallback.
39
+ }
40
+ }
41
+ /** Start the daemon, print + open the first sign-in link, and run until interrupted. */
42
+ export async function runScience() {
43
+ const { server, port, nonces, kernels, origin } = await startScienceServer();
44
+ writeRuntime({ port, pid: process.pid });
45
+ const url = `${origin()}/?nonce=${nonces.create()}`;
46
+ console.log('\n MegaBrain Science is running.');
47
+ console.log(` Open this link to sign in (works once, expires in 3 minutes):\n\n ${url}\n`);
48
+ console.log(' Run `megabrain science url` for a fresh link. Ctrl+C to stop.\n');
49
+ openBrowser(url);
50
+ const shutdown = () => {
51
+ fs.rmSync(RUNTIME_FILE, { force: true });
52
+ kernels.stopAll();
53
+ server.close();
54
+ process.exit(0);
55
+ };
56
+ process.on('SIGINT', shutdown);
57
+ process.on('SIGTERM', shutdown);
58
+ }
59
+ /** Ask the running daemon for a fresh sign-in URL and print it. */
60
+ export async function runScienceUrl() {
61
+ const rt = readRuntime();
62
+ if (!rt) {
63
+ throw new Error('MegaBrain Science is not running. Start it with `megabrain science`.');
64
+ }
65
+ let res;
66
+ try {
67
+ res = await fetch(`http://127.0.0.1:${rt.port}/internal/mint`, { method: 'POST' });
68
+ }
69
+ catch {
70
+ throw new Error('MegaBrain Science is not running. Start it with `megabrain science`.');
71
+ }
72
+ if (!res.ok)
73
+ throw new Error(`Could not mint a sign-in link (HTTP ${res.status}).`);
74
+ const { url } = (await res.json());
75
+ console.log(`\n ${url}\n`);
76
+ openBrowser(url);
77
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Kernel Protocol — the transport-agnostic contract the local kernel speaks (ported from the
3
+ * retired Electron app's `shared/kernel-protocol.ts`). The daemon writes KernelRequest lines
4
+ * to the kernel's stdin and reads KernelFrame lines from its stdout (newline-delimited JSON).
5
+ * State persists across cells for the process lifetime — the "kernel shared with the agent".
6
+ */
7
+ export {};
@@ -0,0 +1,117 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { existsSync } from 'node:fs';
3
+ import { fileURLToPath } from 'node:url';
4
+ /**
5
+ * The local Python kernel — MegaBrain Science's differentiator (#274, reference §0/§1.2). The
6
+ * agent's model runs in the cloud (via the Gateway), but code executes HERE, on the user's
7
+ * machine, in a long-lived process whose namespace persists across cells. Raw data never
8
+ * leaves the machine; only code and chosen results do.
9
+ *
10
+ * `KernelBridge` spawns `python3 -u kernel_driver.py` and speaks the Kernel Protocol over
11
+ * newline-delimited JSON on stdio, streaming frames to subscribers. `KernelManager` keeps one
12
+ * bridge per session. This is the piece kept from the retired Electron app; the launcher is
13
+ * pluggable so an SSH/HPC runtime can replace `spawn` later without touching the protocol.
14
+ */
15
+ export const KERNEL_DRIVER_PATH = fileURLToPath(new URL('./kernel_driver.py', import.meta.url));
16
+ const PYTHON_BIN = process.env.MB_PYTHON_BIN ?? 'python3';
17
+ export class KernelBridge {
18
+ driverPath;
19
+ child = null;
20
+ buffer = '';
21
+ handlers = new Set();
22
+ cellCounter = 0;
23
+ constructor(driverPath = KERNEL_DRIVER_PATH) {
24
+ this.driverPath = driverPath;
25
+ }
26
+ onFrame(handler) {
27
+ this.handlers.add(handler);
28
+ return () => this.handlers.delete(handler);
29
+ }
30
+ start() {
31
+ if (this.child)
32
+ return;
33
+ this.emit({ type: 'status', state: 'starting' });
34
+ if (!existsSync(this.driverPath)) {
35
+ this.emit({
36
+ type: 'status',
37
+ state: 'error',
38
+ error: `kernel driver missing: ${this.driverPath}`,
39
+ });
40
+ return;
41
+ }
42
+ const child = spawn(PYTHON_BIN, ['-u', this.driverPath], { stdio: ['pipe', 'pipe', 'pipe'] });
43
+ this.child = child;
44
+ child.stdout.on('data', (c) => this.onStdout(c));
45
+ child.stderr.on('data', (c) => this.emit({ type: 'status', state: 'error', error: c.toString('utf8') }));
46
+ child.on('error', err => this.emit({ type: 'status', state: 'error', error: `kernel failed to start: ${err.message}` }));
47
+ child.on('exit', code => {
48
+ this.child = null;
49
+ this.emit({ type: 'status', state: 'error', error: `kernel exited (code ${code})` });
50
+ });
51
+ }
52
+ /** Run a cell; returns the assigned cellId so callers can correlate frames. */
53
+ execute(code) {
54
+ if (!this.child)
55
+ this.start();
56
+ this.cellCounter += 1;
57
+ const cellId = `cell-${this.cellCounter}`;
58
+ this.send({ type: 'execute', cellId, code });
59
+ return cellId;
60
+ }
61
+ send(request) {
62
+ if (!this.child)
63
+ this.start();
64
+ this.child?.stdin.write(`${JSON.stringify(request)}\n`);
65
+ }
66
+ restart() {
67
+ this.stop();
68
+ this.buffer = '';
69
+ this.start();
70
+ }
71
+ stop() {
72
+ this.child?.kill();
73
+ this.child = null;
74
+ }
75
+ onStdout(chunk) {
76
+ this.buffer += chunk.toString('utf8');
77
+ let nl = this.buffer.indexOf('\n');
78
+ while (nl !== -1) {
79
+ const line = this.buffer.slice(0, nl).trim();
80
+ this.buffer = this.buffer.slice(nl + 1);
81
+ if (line) {
82
+ try {
83
+ this.emit(JSON.parse(line));
84
+ }
85
+ catch {
86
+ this.emit({ type: 'stream', cellId: '', kind: 'stdout', data: line });
87
+ }
88
+ }
89
+ nl = this.buffer.indexOf('\n');
90
+ }
91
+ }
92
+ emit(frame) {
93
+ for (const h of this.handlers)
94
+ h(frame);
95
+ }
96
+ }
97
+ /** One kernel per session, started on demand. */
98
+ export class KernelManager {
99
+ bridges = new Map();
100
+ get(sessionId) {
101
+ let bridge = this.bridges.get(sessionId);
102
+ if (!bridge) {
103
+ bridge = new KernelBridge();
104
+ bridge.start();
105
+ this.bridges.set(sessionId, bridge);
106
+ }
107
+ return bridge;
108
+ }
109
+ restart(sessionId) {
110
+ this.bridges.get(sessionId)?.restart();
111
+ }
112
+ stopAll() {
113
+ for (const bridge of this.bridges.values())
114
+ bridge.stop();
115
+ this.bridges.clear();
116
+ }
117
+ }
@@ -0,0 +1,93 @@
1
+ #!/usr/bin/env python3
2
+ """MegaBrain Science — local Python kernel driver.
3
+
4
+ Implements the Kernel Protocol over newline-delimited JSON on stdio. The Electron
5
+ main process (KernelSupervisor) spawns this with `python3 -u kernel_driver.py`,
6
+ writes KernelRequest lines to stdin, and reads KernelFrame lines from stdout.
7
+
8
+ State (the exec namespace) persists across cells for the process lifetime, which
9
+ is what makes the kernel "shared with the agent": variables and imports set in one
10
+ cell are visible in the next.
11
+ """
12
+
13
+ import contextlib
14
+ import io
15
+ import json
16
+ import sys
17
+ import traceback
18
+
19
+ # Persistent namespace shared across all cells in this session.
20
+ NAMESPACE: dict = {"__name__": "__mb_science__"}
21
+
22
+
23
+ def emit(frame: dict) -> None:
24
+ sys.stdout.write(json.dumps(frame) + "\n")
25
+ sys.stdout.flush()
26
+
27
+
28
+ def run_cell(cell_id: str, code: str) -> None:
29
+ emit({"type": "status", "cellId": cell_id, "state": "busy"})
30
+ stdout, stderr = io.StringIO(), io.StringIO()
31
+ try:
32
+ with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr):
33
+ # Compile as a module body so top-level expressions still assign/print
34
+ # normally; the persistent NAMESPACE keeps state between cells.
35
+ exec(compile(code, f"<{cell_id}>", "exec"), NAMESPACE)
36
+ except Exception: # noqa: BLE001 - surface any user error as a stderr stream
37
+ stderr.write(traceback.format_exc())
38
+
39
+ out, err = stdout.getvalue(), stderr.getvalue()
40
+ if out:
41
+ emit({"type": "stream", "cellId": cell_id, "kind": "stdout", "data": out})
42
+ if err:
43
+ emit({"type": "stream", "cellId": cell_id, "kind": "stderr", "data": err})
44
+ emit({"type": "status", "cellId": cell_id, "state": "idle"})
45
+
46
+
47
+ def emit_snapshot() -> None:
48
+ import platform
49
+
50
+ try:
51
+ import importlib.metadata as md
52
+
53
+ packages = sorted(f"{d.metadata['Name']}=={d.version}" for d in md.distributions())
54
+ except Exception: # noqa: BLE001 - environment listing is best-effort
55
+ packages = []
56
+
57
+ emit(
58
+ {
59
+ "type": "snapshot",
60
+ "env": {
61
+ "language": "python",
62
+ "version": platform.python_version(),
63
+ "platform": platform.platform(),
64
+ "packages": packages,
65
+ },
66
+ }
67
+ )
68
+
69
+
70
+ def main() -> None:
71
+ emit({"type": "status", "state": "idle"})
72
+ for raw in sys.stdin:
73
+ line = raw.strip()
74
+ if not line:
75
+ continue
76
+ try:
77
+ request = json.loads(line)
78
+ except json.JSONDecodeError:
79
+ continue
80
+
81
+ kind = request.get("type")
82
+ if kind == "execute":
83
+ run_cell(request.get("cellId", ""), request.get("code", ""))
84
+ elif kind == "snapshot":
85
+ emit_snapshot()
86
+ elif kind == "interrupt":
87
+ # Cooperative interrupt is a follow-up; ack for now.
88
+ emit({"type": "status", "state": "idle"})
89
+ # "restart" is handled by the supervisor by killing/respawning us.
90
+
91
+
92
+ if __name__ == "__main__":
93
+ main()
@@ -0,0 +1,53 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ /**
3
+ * Single-use, short-lived nonces for the browser sign-in hand-off.
4
+ *
5
+ * The `megabrain science` daemon prints a `http://127.0.0.1:PORT/?nonce=…` URL and opens it
6
+ * in the browser. Opening that link is what authorizes *this* browser to drive *this* local
7
+ * daemon — so a random other process on the machine can't. The nonce works once and expires a
8
+ * few minutes after it was printed; `megabrain science url` mints a fresh one. See
9
+ * docs/megabrain-science/reference/claude-science-flow.md §1.1 (screen 01).
10
+ */
11
+ const NONCE_TTL_MS = 3 * 60 * 1000; // 3 minutes, matching the printed hint.
12
+ export class NonceStore {
13
+ now;
14
+ ttlMs;
15
+ nonces = new Map();
16
+ constructor(now = Date.now, ttlMs = NONCE_TTL_MS) {
17
+ this.now = now;
18
+ this.ttlMs = ttlMs;
19
+ }
20
+ /** Mint a fresh nonce and return it (URL-safe hex). */
21
+ create() {
22
+ this.sweep();
23
+ const nonce = randomBytes(20).toString('hex');
24
+ this.nonces.set(nonce, { createdAt: this.now(), used: false });
25
+ return nonce;
26
+ }
27
+ /**
28
+ * Validate and consume a nonce. Single-use: a second claim of the same nonce returns
29
+ * 'used'. Returns 'unknown' for a nonce we never issued (or already swept), 'expired'
30
+ * past the TTL.
31
+ */
32
+ claim(nonce) {
33
+ const entry = this.nonces.get(nonce);
34
+ if (!entry)
35
+ return 'unknown';
36
+ if (this.now() - entry.createdAt > this.ttlMs) {
37
+ this.nonces.delete(nonce);
38
+ return 'expired';
39
+ }
40
+ if (entry.used)
41
+ return 'used';
42
+ entry.used = true;
43
+ return 'ok';
44
+ }
45
+ /** Drop expired entries so the map doesn't grow unbounded across `url` re-mints. */
46
+ sweep() {
47
+ const cutoff = this.now() - this.ttlMs;
48
+ for (const [nonce, entry] of this.nonces) {
49
+ if (entry.createdAt < cutoff)
50
+ this.nonces.delete(nonce);
51
+ }
52
+ }
53
+ }
@@ -0,0 +1,295 @@
1
+ /**
2
+ * Server-rendered HTML for the sign-in hand-off. Mirrors the Claude Science screens
3
+ * (docs/megabrain-science/reference/claude-science-flow.md §1.1), MegaBrain-branded. These
4
+ * are deliberately dependency-free inline pages — the rich session UI (reused
5
+ * `cloud-agent-next`) mounts on the shell later; this is just the auth entry.
6
+ */
7
+ function shell(title, body) {
8
+ return `<!doctype html>
9
+ <html lang="en">
10
+ <head>
11
+ <meta charset="utf-8" />
12
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
13
+ <title>${title}</title>
14
+ <style>
15
+ :root { color-scheme: light dark; }
16
+ * { box-sizing: border-box; }
17
+ body {
18
+ margin: 0; min-height: 100vh; display: grid; place-items: center;
19
+ font: 15px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
20
+ background: #fafafa; color: #111;
21
+ }
22
+ @media (prefers-color-scheme: dark) { body { background: #0a0a0a; color: #ededed; } }
23
+ .card {
24
+ width: min(92vw, 420px); padding: 40px 36px; border-radius: 20px; text-align: center;
25
+ background: #fff; box-shadow: 0 1px 3px rgba(0,0,0,.06), 0 12px 40px rgba(0,0,0,.08);
26
+ }
27
+ @media (prefers-color-scheme: dark) { .card { background: #161616; box-shadow: none; border: 1px solid #262626; } }
28
+ .mark { width: 56px; height: 56px; margin: 0 auto 20px; border-radius: 14px;
29
+ background: linear-gradient(135deg, #e8623a, #c8492a); display: grid; place-items: center;
30
+ color: #fff; font-weight: 700; font-size: 20px; }
31
+ h1 { font-size: 22px; margin: 0 0 6px; letter-spacing: -.01em; }
32
+ .beta { font-size: 12px; color: #888; margin-bottom: 24px; }
33
+ p { color: #555; margin: 0 0 24px; }
34
+ @media (prefers-color-scheme: dark) { p { color: #a3a3a3; } }
35
+ code { background: rgba(0,0,0,.06); padding: 1px 6px; border-radius: 5px; font-size: .9em; }
36
+ @media (prefers-color-scheme: dark) { code { background: rgba(255,255,255,.1); } }
37
+ a.btn, button.btn {
38
+ display: block; width: 100%; padding: 12px 16px; margin-top: 10px; border-radius: 12px;
39
+ font: inherit; font-weight: 600; cursor: pointer; border: 1px solid transparent;
40
+ text-decoration: none; text-align: center;
41
+ }
42
+ .btn-primary { background: #111; color: #fff; }
43
+ @media (prefers-color-scheme: dark) { .btn-primary { background: #ededed; color: #111; } }
44
+ .btn-ghost { background: transparent; border-color: #d4d4d4; color: inherit; }
45
+ @media (prefers-color-scheme: dark) { .btn-ghost { border-color: #333; } }
46
+ .status { margin-top: 18px; font-size: 13px; color: #888; min-height: 1.5em; }
47
+ .status a { color: inherit; }
48
+ </style>
49
+ </head>
50
+ <body>
51
+ <div class="card">${body}</div>
52
+ </body>
53
+ </html>`;
54
+ }
55
+ /** Screen 01 — the nonce landing that authorizes this browser for this daemon. */
56
+ export function nonceLandingPage(nonce) {
57
+ return shell('Sign in — MegaBrain Science', `<div class="mark">MB</div>
58
+ <h1>Sign in to MegaBrain Science</h1>
59
+ <p>Click below to finish signing in on this browser. This link works once, and expires
60
+ 3 minutes after it was printed — if the button fails, run
61
+ <code>megabrain science url</code> for a fresh link.</p>
62
+ <a class="btn btn-primary" href="/login?nonce=${encodeURIComponent(nonce)}">Sign in</a>`);
63
+ }
64
+ /** Shown when a nonce link is reused/expired/unknown. */
65
+ export function nonceInvalidPage() {
66
+ return shell('Link expired — MegaBrain Science', `<div class="mark">MB</div>
67
+ <h1>This link has expired</h1>
68
+ <p>Sign-in links work once and expire after 3 minutes. Run
69
+ <code>megabrain science url</code> in your terminal for a fresh link.</p>`);
70
+ }
71
+ /** Screen 02 — the login card (MegaBrain OAuth / paste code). */
72
+ export function loginPage() {
73
+ return shell('MegaBrain Science', `<div class="mark">MB</div>
74
+ <h1>MegaBrain Science</h1>
75
+ <div class="beta">Beta</div>
76
+ <button class="btn btn-primary" id="signin">Sign in with MegaBrain</button>
77
+ <button class="btn btn-ghost" id="paste">Paste code instead</button>
78
+ <div class="status" id="status"></div>
79
+ <script>
80
+ const statusEl = document.getElementById('status');
81
+ async function start() {
82
+ document.getElementById('signin').disabled = true;
83
+ statusEl.textContent = 'Starting sign-in…';
84
+ const res = await fetch('/api/signin', { method: 'POST' });
85
+ if (!res.ok) { statusEl.textContent = 'Could not start sign-in. Try again.'; return; }
86
+ const { verificationUrl, code } = await res.json();
87
+ statusEl.innerHTML = 'Confirm code <code>' + code + '</code> at <a href="' +
88
+ verificationUrl + '" target="_blank" rel="noreferrer">' + verificationUrl + '</a>';
89
+ poll();
90
+ }
91
+ async function poll() {
92
+ const res = await fetch('/api/auth/status');
93
+ const s = await res.json();
94
+ if (s.signedIn) { statusEl.textContent = 'Signed in. Opening…'; location.href = '/'; return; }
95
+ setTimeout(poll, 2000);
96
+ }
97
+ document.getElementById('signin').addEventListener('click', start);
98
+ // The code is minted server-side and shown here, so "paste" starts the same flow.
99
+ document.getElementById('paste').addEventListener('click', start);
100
+ </script>`);
101
+ }
102
+ function esc(s) {
103
+ return s.replace(/[&<>"']/g, c => c === '&' ? '&amp;' : c === '<' ? '&lt;' : c === '>' ? '&gt;' : c === '"' ? '&quot;' : '&#39;');
104
+ }
105
+ /**
106
+ * Home — Projects + Recent sessions (#270). A thin server-rendered placeholder that reads the
107
+ * daemon's workspace API; the reused cloud-agent-next UI replaces the markup later, but the
108
+ * `/api/*` contract it exercises here is the durable part.
109
+ */
110
+ export function homePage(userEmail) {
111
+ return shell('MegaBrain Science', `<div class="mark">MB</div>
112
+ <h1>MegaBrain Science</h1>
113
+ <div class="beta">${esc(userEmail)}</div>
114
+ <div style="text-align:left;margin-top:8px">
115
+ <div style="display:flex;justify-content:space-between;align-items:center">
116
+ <strong>Projects</strong>
117
+ <button class="btn btn-ghost" style="width:auto;padding:6px 12px;margin:0" id="new">+ New project</button>
118
+ </div>
119
+ <div id="projects" class="status">Loading…</div>
120
+ <strong style="display:block;margin-top:20px">Recent sessions</strong>
121
+ <div id="recent" class="status"></div>
122
+ </div>
123
+ <button class="btn btn-ghost" id="out" style="margin-top:20px">Sign out</button>
124
+ <script>
125
+ async function load() {
126
+ const [projects, recent] = await Promise.all([
127
+ fetch('/api/projects').then(r => r.json()),
128
+ fetch('/api/sessions/recent').then(r => r.json()),
129
+ ]);
130
+ document.getElementById('projects').innerHTML = projects.length
131
+ ? projects.map(p => '<a href="/projects/' + p.id + '" style="display:block;color:inherit">' +
132
+ p.name + ' · ' + p.sessionCount + ' session(s)</a>').join('')
133
+ : 'No projects yet.';
134
+ document.getElementById('recent').innerHTML = recent.length
135
+ ? recent.map(s => '<a href="/projects/' + s.projectId + '/frames/' + s.id +
136
+ '" style="display:block;color:inherit">' + s.title + '</a>').join('')
137
+ : 'No sessions yet.';
138
+ }
139
+ document.getElementById('new').addEventListener('click', async () => {
140
+ const name = prompt('Project name:'); if (!name) return;
141
+ const p = await fetch('/api/projects', { method: 'POST',
142
+ headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name }) }).then(r => r.json());
143
+ location.href = '/projects/' + p.id;
144
+ });
145
+ document.getElementById('out').addEventListener('click', async () => {
146
+ await fetch('/api/signout', { method: 'POST' }); location.href = '/login';
147
+ });
148
+ load();
149
+ </script>`);
150
+ }
151
+ /** A single project: its sessions + New session (#271). Placeholder markup, real API. */
152
+ export function projectPage(projectId) {
153
+ return shell('Project — MegaBrain Science', `<div class="mark">MB</div>
154
+ <h1 id="title">Project</h1>
155
+ <div style="text-align:left">
156
+ <div style="display:flex;justify-content:space-between;align-items:center">
157
+ <strong>Sessions</strong>
158
+ <button class="btn btn-ghost" style="width:auto;padding:6px 12px;margin:0" id="new">+ New</button>
159
+ </div>
160
+ <div id="sessions" class="status">Loading…</div>
161
+ </div>
162
+ <a class="btn btn-ghost" href="/" style="margin-top:20px">← Home</a>
163
+ <script>
164
+ const pid = ${JSON.stringify(projectId)};
165
+ async function load() {
166
+ const p = await fetch('/api/projects/' + pid).then(r => r.json());
167
+ if (p.project) document.getElementById('title').textContent = p.project.name;
168
+ document.getElementById('sessions').innerHTML = (p.sessions || []).length
169
+ ? p.sessions.map(s => '<a href="/projects/' + pid + '/frames/' + s.id +
170
+ '" style="display:block;color:inherit">' + s.title + '</a>').join('')
171
+ : 'No sessions yet.';
172
+ }
173
+ document.getElementById('new').addEventListener('click', async () => {
174
+ const s = await fetch('/api/projects/' + pid + '/sessions', { method: 'POST',
175
+ headers: { 'content-type': 'application/json' }, body: JSON.stringify({ title: 'New session' }) }).then(r => r.json());
176
+ location.href = '/projects/' + pid + '/frames/' + s.id;
177
+ });
178
+ load();
179
+ </script>`);
180
+ }
181
+ /**
182
+ * `/start` — the 5-step first-run onboarding wizard (reference §1.3, #286): scientific-web
183
+ * egress → connectors & skills → research profile → first task. Server-rendered; persists via
184
+ * `/api/onboarding` and creates the first project/session on "Start".
185
+ */
186
+ export function startWizardPage(d) {
187
+ const checks = (items, on, group) => items
188
+ .map(i => `<label style="display:flex;justify-content:space-between;gap:12px;padding:8px 0;border-bottom:1px solid rgba(128,128,128,.15)">
189
+ <span>${esc(i)}</span>
190
+ <input type="checkbox" data-group="${group}" value="${esc(i)}" ${on.includes(i) ? 'checked' : ''} />
191
+ </label>`)
192
+ .join('');
193
+ const TASKS = [
194
+ [
195
+ 'Map the recent literature of your subfield',
196
+ 'Pull the last year of papers, extract key quantitative findings, and write a structured review with citations and a suggested next experiment.',
197
+ ],
198
+ [
199
+ 'Run a first-pass analysis on a dataset you already have',
200
+ 'Point the agent at your data — it runs QC and a first analysis pass and returns figures plus a written summary.',
201
+ ],
202
+ [
203
+ 'Turn a workflow you repeat by hand into a reusable pipeline',
204
+ 'Describe the steps you do manually — the agent builds a documented, rerunnable pipeline and demonstrates it end-to-end.',
205
+ ],
206
+ ];
207
+ const taskCards = TASKS.map(([t, sub], i) => `<label style="display:block;text-align:left;border:1px solid rgba(128,128,128,.25);border-radius:12px;padding:12px;margin:8px 0;cursor:pointer">
208
+ <input type="radio" name="task" value="${esc(t)}" ${i === 0 ? 'checked' : ''} />
209
+ <strong>${esc(t)}</strong><br /><span class="status">${esc(sub)}</span>
210
+ </label>`).join('');
211
+ return shell('Set up — MegaBrain Science', `<div style="text-align:left">
212
+ <section data-step="0"><h1>Connect to the scientific web</h1>
213
+ <p>Choose which sources the agent can pull papers, sequences, and structures from.</p>
214
+ ${checks(d.networkCategories, d.network, 'network')}</section>
215
+ <section data-step="1" hidden><h1>Connectors &amp; skills</h1>
216
+ <p>Work directly with databases, research tools, and open science models.</p>
217
+ ${checks(d.connectorCatalog, d.connectors, 'connectors')}</section>
218
+ <section data-step="2" hidden><h1>What do you work on?</h1>
219
+ <p>Describe your field, methods, and current project.</p>
220
+ <textarea id="profile" rows="5" style="width:100%" placeholder="e.g. I'm a postdoc doing single-cell RNA-seq on zebrafish heart regeneration — mostly Seurat + our own Nextflow pipelines.">${esc(d.profile)}</textarea></section>
221
+ <section data-step="3" hidden><h1>Where should we start?</h1>
222
+ <p>Pick a first task, or describe your own.</p>${taskCards}
223
+ <input id="customTask" style="width:100%;margin-top:8px" placeholder="Describe your own first task (optional)" /></section>
224
+ <div style="display:flex;justify-content:space-between;margin-top:20px">
225
+ <button class="btn btn-ghost" style="width:auto;padding:8px 16px;margin:0" id="back">Back</button>
226
+ <span class="status" id="dots"></span>
227
+ <button class="btn btn-primary" style="width:auto;padding:8px 16px;margin:0" id="next">Continue</button>
228
+ </div>
229
+ </div>
230
+ <script>
231
+ const steps = [...document.querySelectorAll('section[data-step]')];
232
+ let i = 0;
233
+ const gather = g => [...document.querySelectorAll('input[data-group="' + g + '"]:checked')].map(e => e.value);
234
+ function render() {
235
+ steps.forEach((s, n) => (s.hidden = n !== i));
236
+ document.getElementById('back').style.visibility = i === 0 ? 'hidden' : 'visible';
237
+ document.getElementById('next').textContent = i === steps.length - 1 ? 'Start' : 'Continue';
238
+ document.getElementById('dots').textContent = (i + 1) + ' / ' + steps.length;
239
+ }
240
+ document.getElementById('back').addEventListener('click', () => { if (i > 0) { i--; render(); } });
241
+ document.getElementById('next').addEventListener('click', async () => {
242
+ if (i < steps.length - 1) { i++; render(); return; }
243
+ const profile = document.getElementById('profile').value;
244
+ await fetch('/api/onboarding', { method: 'PUT', headers: { 'content-type': 'application/json' },
245
+ body: JSON.stringify({ network: gather('network'), connectors: gather('connectors'), profile, onboarded: true }) });
246
+ const task = document.getElementById('customTask').value.trim() ||
247
+ (document.querySelector('input[name="task"]:checked') || {}).value || '';
248
+ const r = await fetch('/api/onboarding/start', { method: 'POST', headers: { 'content-type': 'application/json' },
249
+ body: JSON.stringify({ task, profile }) }).then(r => r.json());
250
+ location.href = '/projects/' + r.projectId + '/frames/' + r.sessionId;
251
+ });
252
+ render();
253
+ </script>`);
254
+ }
255
+ /**
256
+ * A working session with a live local-kernel REPL (#274/#277). The transcript + composer
257
+ * (reused cloud-agent-next) replaces this markup later; the kernel SSE + execute contract it
258
+ * exercises is the durable part — compute runs locally, streamed over Server-Sent Events.
259
+ */
260
+ export function sessionPage(projectId, sessionId) {
261
+ const sid = JSON.stringify(sessionId);
262
+ return shell('Session — MegaBrain Science', `<div style="text-align:left">
263
+ <h1 id="title" style="font-size:18px">Session</h1>
264
+ <div class="status" id="kstate">kernel: starting…</div>
265
+ <div id="out" style="font:12px/1.5 ui-monospace,Menlo,monospace;white-space:pre-wrap;background:rgba(128,128,128,.08);border-radius:10px;padding:12px;min-height:80px;max-height:40vh;overflow:auto"></div>
266
+ <textarea id="code" rows="4" spellcheck="false" style="width:100%;margin-top:10px;font:12px/1.5 ui-monospace,Menlo,monospace" placeholder="print(6 * 7)"></textarea>
267
+ <div style="display:flex;gap:8px;margin-top:8px">
268
+ <button class="btn btn-primary" style="width:auto;padding:8px 16px;margin:0" id="run">Run ⌘⏎</button>
269
+ <button class="btn btn-ghost" style="width:auto;padding:8px 16px;margin:0" id="restart">Restart kernel</button>
270
+ <a class="btn btn-ghost" style="width:auto;padding:8px 16px;margin:0" href="/projects/${esc(projectId)}">← Project</a>
271
+ </div>
272
+ </div>
273
+ <script>
274
+ const sid = ${sid};
275
+ const out = document.getElementById('out');
276
+ fetch('/api/sessions/' + sid).then(r => r.json()).then(s => {
277
+ if (s && s.title) document.getElementById('title').textContent = s.title;
278
+ });
279
+ const es = new EventSource('/api/sessions/' + sid + '/kernel/stream');
280
+ es.onmessage = e => {
281
+ const f = JSON.parse(e.data);
282
+ if (f.type === 'status') document.getElementById('kstate').textContent = 'kernel: ' + f.state + (f.error ? ' — ' + f.error : '');
283
+ else if (f.type === 'stream') { out.textContent += f.data; out.scrollTop = out.scrollHeight; }
284
+ };
285
+ async function run() {
286
+ const code = document.getElementById('code').value;
287
+ if (!code.trim()) return;
288
+ out.textContent += '>>> ' + code + '\\n';
289
+ await fetch('/api/sessions/' + sid + '/kernel/execute', { method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify({ code }) });
290
+ }
291
+ document.getElementById('run').addEventListener('click', run);
292
+ document.getElementById('code').addEventListener('keydown', e => { if ((e.metaKey||e.ctrlKey) && e.key==='Enter') { e.preventDefault(); run(); } });
293
+ document.getElementById('restart').addEventListener('click', () => fetch('/api/sessions/' + sid + '/kernel/restart', { method:'POST' }));
294
+ </script>`);
295
+ }
@@ -0,0 +1,306 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { createServer } from 'node:http';
3
+ import { readCredentials, writeCredentials, clearCredentials } from '../credentials.js';
4
+ import { startDeviceAuth } from '../device-auth.js';
5
+ import { NonceStore } from './nonce.js';
6
+ import { homePage, loginPage, nonceInvalidPage, nonceLandingPage, projectPage, sessionPage, startWizardPage, } from './pages.js';
7
+ import { FEATURED_CONNECTORS, NETWORK_CATEGORIES, WorkspaceStore } from './store.js';
8
+ import { KernelManager } from './kernel.js';
9
+ /**
10
+ * The `megabrain science` local daemon's HTTP server. Serves the sign-in hand-off on
11
+ * localhost (docs/megabrain-science/reference/claude-science-flow.md §1.1) and, once signed
12
+ * in, a placeholder app shell where the reused session UI will mount. Two trust layers:
13
+ * 1. a single-use nonce authorizes a browser to drive this daemon (a printed localhost URL),
14
+ * 2. MegaBrain device-auth authenticates the account (reused from the CLI).
15
+ */
16
+ const COOKIE = 'mb_sci';
17
+ const PREFERRED_PORT = 8765;
18
+ export async function startScienceServer(host = '127.0.0.1') {
19
+ const nonces = new NonceStore();
20
+ const trusted = new Set();
21
+ const store = new WorkspaceStore();
22
+ const kernels = new KernelManager();
23
+ let pending = null;
24
+ const isSignedIn = () => readCredentials() !== null;
25
+ const readBody = (req) => new Promise(resolve => {
26
+ let raw = '';
27
+ req.on('data', chunk => {
28
+ raw += chunk;
29
+ if (raw.length > 1_000_000)
30
+ req.destroy(); // guard against oversized bodies
31
+ });
32
+ req.on('end', () => {
33
+ try {
34
+ resolve(raw ? JSON.parse(raw) : {});
35
+ }
36
+ catch {
37
+ resolve({});
38
+ }
39
+ });
40
+ req.on('error', () => resolve({}));
41
+ });
42
+ const str = (v) => (typeof v === 'string' ? v : undefined);
43
+ const cookieId = (req) => {
44
+ const raw = req.headers.cookie ?? '';
45
+ for (const part of raw.split(';')) {
46
+ const [k, v] = part.trim().split('=');
47
+ if (k === COOKIE && v)
48
+ return v;
49
+ }
50
+ return null;
51
+ };
52
+ const isTrusted = (req) => {
53
+ const id = cookieId(req);
54
+ return id !== null && trusted.has(id);
55
+ };
56
+ const html = (res, body, status = 200) => {
57
+ res.writeHead(status, { 'content-type': 'text/html; charset=utf-8' });
58
+ res.end(body);
59
+ };
60
+ const json = (res, body, status = 200) => {
61
+ res.writeHead(status, { 'content-type': 'application/json' });
62
+ res.end(JSON.stringify(body));
63
+ };
64
+ const redirect = (res, location) => {
65
+ res.writeHead(302, { location });
66
+ res.end();
67
+ };
68
+ const server = createServer(async (req, res) => {
69
+ const url = new URL(req.url ?? '/', `http://${req.headers.host ?? host}`);
70
+ const path = url.pathname;
71
+ const method = req.method ?? 'GET';
72
+ const seg = path.split('/').filter(Boolean);
73
+ // ── Sign-in hand-off ───────────────────────────────────────────────
74
+ if (path === '/' && url.searchParams.has('nonce')) {
75
+ const result = nonces.claim(url.searchParams.get('nonce') ?? '');
76
+ if (result !== 'ok')
77
+ return html(res, nonceInvalidPage(), 410);
78
+ const id = randomBytes(18).toString('hex');
79
+ trusted.add(id);
80
+ res.setHeader('set-cookie', `${COOKIE}=${id}; HttpOnly; SameSite=Lax; Path=/`);
81
+ return html(res, nonceLandingPage(url.searchParams.get('nonce') ?? ''));
82
+ }
83
+ if (path === '/') {
84
+ if (!isTrusted(req))
85
+ return html(res, nonceInvalidPage(), 401);
86
+ const creds = readCredentials();
87
+ if (!creds)
88
+ return redirect(res, '/login');
89
+ // First run: send new accounts through the onboarding wizard before Home.
90
+ if (!store.getSettings().onboarded)
91
+ return redirect(res, '/start');
92
+ return html(res, homePage(creds.userEmail));
93
+ }
94
+ if (path === '/login' && method === 'GET') {
95
+ if (!isTrusted(req))
96
+ return html(res, nonceInvalidPage(), 401);
97
+ return isSignedIn() ? redirect(res, '/') : html(res, loginPage());
98
+ }
99
+ if (path === '/start' && method === 'GET') {
100
+ if (!isTrusted(req))
101
+ return html(res, nonceInvalidPage(), 401);
102
+ if (!isSignedIn())
103
+ return redirect(res, '/login');
104
+ const s = store.getSettings();
105
+ return html(res, startWizardPage({
106
+ networkCategories: [...NETWORK_CATEGORIES],
107
+ connectorCatalog: [...FEATURED_CONNECTORS],
108
+ network: s.network,
109
+ connectors: s.connectors,
110
+ profile: s.profile,
111
+ }));
112
+ }
113
+ // ── Workbench pages (trusted + signed in) ───────────────────────────
114
+ if (seg[0] === 'projects' && method === 'GET') {
115
+ if (!isTrusted(req))
116
+ return html(res, nonceInvalidPage(), 401);
117
+ if (!isSignedIn())
118
+ return redirect(res, '/login');
119
+ if (seg.length === 2)
120
+ return html(res, projectPage(seg[1] ?? ''));
121
+ if (seg.length === 4 && seg[2] === 'frames') {
122
+ return html(res, sessionPage(seg[1] ?? '', seg[3] ?? ''));
123
+ }
124
+ }
125
+ // ── Auth API (browser-driven, gated on the local-trust cookie) ──────
126
+ if (path === '/api/signin' && method === 'POST') {
127
+ if (!isTrusted(req))
128
+ return json(res, { error: 'untrusted' }, 401);
129
+ if (isSignedIn())
130
+ return json(res, { signedIn: true });
131
+ const respond = (p) => json(res, { verificationUrl: p.verificationUrl, code: p.code });
132
+ if (pending)
133
+ return respond(pending);
134
+ void startDeviceAuth()
135
+ .then(p => {
136
+ pending = p;
137
+ p.completed
138
+ .then(creds => writeCredentials(creds))
139
+ .catch(() => {
140
+ /* denied / expired — the browser can retry, which mints a new code */
141
+ })
142
+ .finally(() => {
143
+ pending = null;
144
+ });
145
+ respond(p);
146
+ })
147
+ .catch(() => json(res, { error: 'start-failed' }, 502));
148
+ return;
149
+ }
150
+ if (path === '/api/auth/status' && method === 'GET') {
151
+ const creds = readCredentials();
152
+ return json(res, { signedIn: creds !== null, userEmail: creds?.userEmail ?? null });
153
+ }
154
+ if (path === '/api/signout' && method === 'POST') {
155
+ clearCredentials();
156
+ return json(res, { ok: true });
157
+ }
158
+ // ── Onboarding API (#286). Signed-in only. ──────────────────────────
159
+ if (path === '/api/onboarding') {
160
+ if (!isSignedIn())
161
+ return json(res, { error: 'signed-out' }, 401);
162
+ if (method === 'GET')
163
+ return json(res, store.getSettings());
164
+ if (method === 'PUT') {
165
+ const b = await readBody(req);
166
+ const arr = (v) => Array.isArray(v) ? v.filter((x) => typeof x === 'string') : undefined;
167
+ return json(res, store.updateSettings({
168
+ network: arr(b.network),
169
+ connectors: arr(b.connectors),
170
+ skills: arr(b.skills),
171
+ profile: str(b.profile),
172
+ onboarded: typeof b.onboarded === 'boolean' ? b.onboarded : undefined,
173
+ }));
174
+ }
175
+ }
176
+ if (path === '/api/onboarding/start' && method === 'POST') {
177
+ if (!isSignedIn())
178
+ return json(res, { error: 'signed-out' }, 401);
179
+ const b = await readBody(req);
180
+ const task = str(b.task)?.trim() ?? '';
181
+ const profile = str(b.profile)?.trim() ?? '';
182
+ store.updateSettings({ onboarded: true, ...(profile ? { profile } : {}) });
183
+ const project = store.createProject({
184
+ name: profile ? profile.slice(0, 60) : 'My research',
185
+ agentContext: profile,
186
+ });
187
+ const session = store.createSession({
188
+ projectId: project.id,
189
+ title: task || 'New session',
190
+ summary: task,
191
+ });
192
+ return json(res, { projectId: project.id, sessionId: session?.id ?? null }, 201);
193
+ }
194
+ // ── Local kernel (#274). Per-session Python process; SSE for live frames. ──
195
+ if (seg[0] === 'api' && seg[1] === 'sessions' && seg[2] && seg[3] === 'kernel') {
196
+ if (!isSignedIn())
197
+ return json(res, { error: 'signed-out' }, 401);
198
+ const sid = seg[2];
199
+ if (seg[4] === 'stream' && method === 'GET') {
200
+ res.writeHead(200, {
201
+ 'content-type': 'text/event-stream',
202
+ 'cache-control': 'no-cache',
203
+ connection: 'keep-alive',
204
+ });
205
+ const off = kernels
206
+ .get(sid)
207
+ .onFrame(frame => res.write(`data: ${JSON.stringify(frame)}\n\n`));
208
+ req.on('close', off);
209
+ return;
210
+ }
211
+ if (seg[4] === 'execute' && method === 'POST') {
212
+ const b = await readBody(req);
213
+ const cellId = kernels.get(sid).execute(str(b.code) ?? '');
214
+ store.touchSession(sid);
215
+ return json(res, { cellId });
216
+ }
217
+ if (seg[4] === 'restart' && method === 'POST') {
218
+ kernels.restart(sid);
219
+ return json(res, { ok: true });
220
+ }
221
+ }
222
+ // ── Workspace API (projects/sessions, #270/#271). Signed-in only. ────
223
+ if (path.startsWith('/api/projects') || path.startsWith('/api/sessions')) {
224
+ if (!isSignedIn())
225
+ return json(res, { error: 'signed-out' }, 401);
226
+ if (path === '/api/projects' && method === 'GET')
227
+ return json(res, store.listProjects());
228
+ if (path === '/api/projects' && method === 'POST') {
229
+ const b = await readBody(req);
230
+ const name = str(b.name);
231
+ if (!name)
232
+ return json(res, { error: 'name-required' }, 400);
233
+ return json(res, store.createProject({
234
+ name,
235
+ description: str(b.description),
236
+ agentContext: str(b.agentContext),
237
+ }), 201);
238
+ }
239
+ if (seg[0] === 'api' && seg[1] === 'projects' && seg[2]) {
240
+ const pid = seg[2];
241
+ if (seg.length === 3 && method === 'GET') {
242
+ const project = store.getProject(pid);
243
+ return project
244
+ ? json(res, { project, sessions: store.listSessions(pid) })
245
+ : json(res, { error: 'not-found' }, 404);
246
+ }
247
+ if (seg.length === 3 && method === 'PATCH') {
248
+ const b = await readBody(req);
249
+ const updated = store.updateProject(pid, {
250
+ name: str(b.name),
251
+ description: str(b.description),
252
+ agentContext: str(b.agentContext),
253
+ });
254
+ return updated ? json(res, updated) : json(res, { error: 'not-found' }, 404);
255
+ }
256
+ if (seg.length === 4 && seg[3] === 'sessions' && method === 'POST') {
257
+ const b = await readBody(req);
258
+ const session = store.createSession({
259
+ projectId: pid,
260
+ title: str(b.title),
261
+ summary: str(b.summary),
262
+ });
263
+ return session ? json(res, session, 201) : json(res, { error: 'not-found' }, 404);
264
+ }
265
+ }
266
+ if (path === '/api/sessions/recent' && method === 'GET') {
267
+ return json(res, store.recentSessions());
268
+ }
269
+ if (seg[0] === 'api' && seg[1] === 'sessions' && seg[2] && method === 'GET') {
270
+ const session = store.getSession(seg[2]);
271
+ return session ? json(res, session) : json(res, { error: 'not-found' }, 404);
272
+ }
273
+ return json(res, { error: 'not-found' }, 404);
274
+ }
275
+ // Mint a fresh sign-in URL for `megabrain science url` (localhost-only, like the daemon
276
+ // itself — any local terminal can ask for a link; the account auth still gates access).
277
+ if (path === '/internal/mint' && method === 'POST') {
278
+ const origin = `http://${host}:${port}`;
279
+ return json(res, { url: `${origin}/?nonce=${nonces.create()}` });
280
+ }
281
+ res.writeHead(404, { 'content-type': 'text/plain' });
282
+ res.end('Not found');
283
+ });
284
+ const port = await listen(server, host, PREFERRED_PORT);
285
+ const origin = () => `http://${host}:${port}`;
286
+ return { server, port, nonces, kernels, origin };
287
+ }
288
+ /** Bind the preferred port, falling back to an OS-assigned one if it's taken. */
289
+ function listen(server, host, preferred) {
290
+ return new Promise((resolve, reject) => {
291
+ const onError = (err) => {
292
+ if (err.code === 'EADDRINUSE') {
293
+ server.listen(0, host);
294
+ return;
295
+ }
296
+ reject(err);
297
+ };
298
+ server.on('error', onError);
299
+ server.on('listening', () => {
300
+ server.off('error', onError);
301
+ const address = server.address();
302
+ resolve(typeof address === 'object' && address ? address.port : preferred);
303
+ });
304
+ server.listen(preferred, host);
305
+ });
306
+ }
@@ -0,0 +1,160 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { randomBytes } from 'node:crypto';
4
+ import { MEGABRAIN_CONFIG_DIR } from '../paths.js';
5
+ /** Scientific-web egress categories offered in onboarding step "Connect to the scientific web". */
6
+ export const NETWORK_CATEGORIES = [
7
+ 'NCBI / NIH',
8
+ 'Genomics & biology',
9
+ 'Proteomics',
10
+ 'Literature & citations',
11
+ 'Clinical & pharma',
12
+ ];
13
+ /** Featured research connectors offered in onboarding step "Connectors & skills". */
14
+ export const FEATURED_CONNECTORS = [
15
+ 'BioMart',
16
+ 'bioRxiv',
17
+ 'Cancer Models',
18
+ 'CellGuide',
19
+ 'PubMed',
20
+ 'OpenAlex',
21
+ ];
22
+ function defaultSettings() {
23
+ return {
24
+ onboarded: false,
25
+ network: [...NETWORK_CATEGORIES],
26
+ connectors: [...FEATURED_CONNECTORS],
27
+ skills: [],
28
+ profile: '',
29
+ };
30
+ }
31
+ const STORE_FILE = path.join(MEGABRAIN_CONFIG_DIR, 'science-workspace.json');
32
+ function id(prefix) {
33
+ return `${prefix}_${randomBytes(8).toString('hex')}`;
34
+ }
35
+ export class WorkspaceStore {
36
+ file;
37
+ now;
38
+ data;
39
+ constructor(file = STORE_FILE, now = () => new Date().toISOString()) {
40
+ this.file = file;
41
+ this.now = now;
42
+ this.data = this.load();
43
+ }
44
+ load() {
45
+ try {
46
+ const parsed = JSON.parse(fs.readFileSync(this.file, 'utf8'));
47
+ return {
48
+ projects: parsed.projects ?? [],
49
+ sessions: parsed.sessions ?? [],
50
+ settings: parsed.settings,
51
+ };
52
+ }
53
+ catch {
54
+ return { projects: [], sessions: [] };
55
+ }
56
+ }
57
+ save() {
58
+ fs.mkdirSync(path.dirname(this.file), { recursive: true });
59
+ // Atomic write so a crash mid-save can't corrupt the workspace.
60
+ const tmp = `${this.file}.${randomBytes(4).toString('hex')}.tmp`;
61
+ fs.writeFileSync(tmp, JSON.stringify(this.data, null, 2), { mode: 0o600 });
62
+ fs.renameSync(tmp, this.file);
63
+ }
64
+ // ── Projects ─────────────────────────────────────────────────────────
65
+ listProjects() {
66
+ return this.data.projects
67
+ .map(p => {
68
+ const sessions = this.data.sessions.filter(s => s.projectId === p.id);
69
+ const lastActivity = sessions.reduce((max, s) => (s.updatedAt > max ? s.updatedAt : max), p.updatedAt);
70
+ return { ...p, sessionCount: sessions.length, lastActivity };
71
+ })
72
+ .sort((a, b) => b.lastActivity.localeCompare(a.lastActivity));
73
+ }
74
+ getProject(projectId) {
75
+ return this.data.projects.find(p => p.id === projectId) ?? null;
76
+ }
77
+ createProject(input) {
78
+ const ts = this.now();
79
+ const project = {
80
+ id: id('proj'),
81
+ name: input.name.trim() || 'Untitled project',
82
+ description: input.description?.trim() ?? '',
83
+ agentContext: input.agentContext?.trim() ?? '',
84
+ createdAt: ts,
85
+ updatedAt: ts,
86
+ };
87
+ this.data.projects.push(project);
88
+ this.save();
89
+ return project;
90
+ }
91
+ updateProject(projectId, patch) {
92
+ const project = this.data.projects.find(p => p.id === projectId);
93
+ if (!project)
94
+ return null;
95
+ if (patch.name !== undefined)
96
+ project.name = patch.name.trim() || project.name;
97
+ if (patch.description !== undefined)
98
+ project.description = patch.description.trim();
99
+ if (patch.agentContext !== undefined)
100
+ project.agentContext = patch.agentContext.trim();
101
+ project.updatedAt = this.now();
102
+ this.save();
103
+ return project;
104
+ }
105
+ // ── Sessions ─────────────────────────────────────────────────────────
106
+ listSessions(projectId) {
107
+ return this.data.sessions
108
+ .filter(s => s.projectId === projectId)
109
+ .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
110
+ }
111
+ recentSessions(limit = 10) {
112
+ return [...this.data.sessions]
113
+ .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
114
+ .slice(0, limit);
115
+ }
116
+ getSession(sessionId) {
117
+ return this.data.sessions.find(s => s.id === sessionId) ?? null;
118
+ }
119
+ createSession(input) {
120
+ if (!this.getProject(input.projectId))
121
+ return null;
122
+ const ts = this.now();
123
+ const session = {
124
+ id: id('sess'),
125
+ projectId: input.projectId,
126
+ title: input.title?.trim() || 'New session',
127
+ summary: input.summary?.trim() ?? '',
128
+ createdAt: ts,
129
+ updatedAt: ts,
130
+ };
131
+ this.data.sessions.push(session);
132
+ this.save();
133
+ return session;
134
+ }
135
+ /** Bump a session's activity timestamp (and optionally its summary/title). */
136
+ touchSession(sessionId, patch) {
137
+ const session = this.data.sessions.find(s => s.id === sessionId);
138
+ if (!session)
139
+ return null;
140
+ if (patch?.title !== undefined)
141
+ session.title = patch.title.trim() || session.title;
142
+ if (patch?.summary !== undefined)
143
+ session.summary = patch.summary.trim();
144
+ session.updatedAt = this.now();
145
+ this.save();
146
+ return session;
147
+ }
148
+ // ── Onboarding settings ──────────────────────────────────────────────
149
+ getSettings() {
150
+ return { ...defaultSettings(), ...this.data.settings };
151
+ }
152
+ updateSettings(patch) {
153
+ const next = { ...this.getSettings(), ...patch };
154
+ if (patch.profile !== undefined)
155
+ next.profile = patch.profile.trim();
156
+ this.data.settings = next;
157
+ this.save();
158
+ return next;
159
+ }
160
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@getmegabrain/cli",
3
- "version": "0.1.5",
4
- "description": "MegaBrain CLI — sign in once, then code from your terminal via OpenCode wired to the MegaBrain Gateway.",
3
+ "version": "0.1.6",
4
+ "description": "MegaBrain CLI — sign in once, then code from your terminal via OpenCode wired to the MegaBrain Gateway, or run `megabrain science` for the local research workbench.",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "bin": {
@@ -28,7 +28,7 @@
28
28
  },
29
29
  "scripts": {
30
30
  "build": "tsc -p tsconfig.build.json",
31
- "postbuild": "chmod +x dist/index.js",
31
+ "postbuild": "node scripts/copy-assets.mjs && chmod +x dist/index.js",
32
32
  "dev": "tsx src/index.ts",
33
33
  "typecheck": "tsgo --noEmit",
34
34
  "test": "vitest run --passWithNoTests",