@getmegabrain/cli 0.1.4 → 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cloud-fork.js +104 -0
- package/dist/device-auth.js +29 -17
- package/dist/index.js +48 -1
- package/dist/science/index.js +77 -0
- package/dist/science/kernel-protocol.js +7 -0
- package/dist/science/kernel.js +117 -0
- package/dist/science/kernel_driver.py +93 -0
- package/dist/science/nonce.js +53 -0
- package/dist/science/pages.js +295 -0
- package/dist/science/server.js +306 -0
- package/dist/science/store.js +160 -0
- package/package.json +3 -3
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `megabrain --session <id> --cloud-fork` — fork a MegaBrain cloud session
|
|
3
|
+
* into a local OpenCode session.
|
|
4
|
+
*
|
|
5
|
+
* The web UI hands users this command for any cloud session. Flow:
|
|
6
|
+
* 1. Download the session export from the session-ingest worker
|
|
7
|
+
* (`GET /api/session/:id/export`, authenticated with the same Kilo user
|
|
8
|
+
* JWT issued by `megabrain login`).
|
|
9
|
+
* 2. `opencode import <file>` to materialize the session locally.
|
|
10
|
+
* 3. Launch `opencode --session <id> --fork` so the user continues on a
|
|
11
|
+
* fork instead of mutating the imported original.
|
|
12
|
+
*/
|
|
13
|
+
import fs from 'node:fs';
|
|
14
|
+
import os from 'node:os';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
export const SESSION_INGEST_URL = (process.env.MEGABRAIN_SESSION_INGEST_URL ?? 'https://session-ingest.vasilij-lukin.workers.dev').replace(/\/+$/, '');
|
|
17
|
+
const SESSION_ID_RE = /^ses_[a-z0-9]+$/i;
|
|
18
|
+
export function isValidSessionId(sessionId) {
|
|
19
|
+
return SESSION_ID_RE.test(sessionId);
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Detects `--cloud-fork` and extracts the `--session <id>` / `--session=<id>`
|
|
23
|
+
* value from raw argv. `--session` stays in passthroughArgs — it is only
|
|
24
|
+
* removed together with `--cloud-fork` by the caller when forking.
|
|
25
|
+
*/
|
|
26
|
+
export function parseCloudForkArgs(argv) {
|
|
27
|
+
let cloudFork = false;
|
|
28
|
+
let sessionId = null;
|
|
29
|
+
const passthroughArgs = [];
|
|
30
|
+
for (let i = 0; i < argv.length; i++) {
|
|
31
|
+
const arg = argv[i];
|
|
32
|
+
if (arg === '--cloud-fork') {
|
|
33
|
+
cloudFork = true;
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
if (arg === '--session' && i + 1 < argv.length) {
|
|
37
|
+
sessionId = argv[i + 1];
|
|
38
|
+
passthroughArgs.push(arg, argv[i + 1]);
|
|
39
|
+
i++;
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
if (arg.startsWith('--session=')) {
|
|
43
|
+
sessionId = arg.slice('--session='.length);
|
|
44
|
+
passthroughArgs.push(arg);
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
passthroughArgs.push(arg);
|
|
48
|
+
}
|
|
49
|
+
return { cloudFork, sessionId, passthroughArgs };
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Guards against error surfaces served as 200 (e.g. `{"detail":"..."}`):
|
|
53
|
+
* a valid OpenCode session export always carries `info.id`, and `opencode
|
|
54
|
+
* import` crashes with a cryptic error when it's missing.
|
|
55
|
+
*/
|
|
56
|
+
export function exportPayloadSessionId(payload) {
|
|
57
|
+
if (payload === null || typeof payload !== 'object')
|
|
58
|
+
return null;
|
|
59
|
+
const info = payload.info;
|
|
60
|
+
if (info === null || typeof info === 'undefined' || typeof info !== 'object')
|
|
61
|
+
return null;
|
|
62
|
+
const id = info.id;
|
|
63
|
+
return typeof id === 'string' && id.length > 0 ? id : null;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Downloads the cloud session export to a temp file and returns its path.
|
|
67
|
+
* The caller owns cleanup.
|
|
68
|
+
*/
|
|
69
|
+
export async function downloadSessionExport(sessionId, token) {
|
|
70
|
+
const url = `${SESSION_INGEST_URL}/api/session/${encodeURIComponent(sessionId)}/export`;
|
|
71
|
+
let res;
|
|
72
|
+
try {
|
|
73
|
+
res = await fetch(url, {
|
|
74
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
75
|
+
signal: AbortSignal.timeout(300_000),
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
catch (err) {
|
|
79
|
+
throw new Error(`Could not reach MegaBrain to download session ${sessionId}: ${err instanceof Error ? err.message : String(err)}`);
|
|
80
|
+
}
|
|
81
|
+
if (res.status === 404) {
|
|
82
|
+
throw new Error(`Cloud session ${sessionId} was not found (or does not belong to your account).`);
|
|
83
|
+
}
|
|
84
|
+
if (res.status === 401 || res.status === 403) {
|
|
85
|
+
throw new Error('Your MegaBrain sign-in is no longer valid. Run `megabrain login` and retry.');
|
|
86
|
+
}
|
|
87
|
+
if (!res.ok) {
|
|
88
|
+
throw new Error(`Downloading session ${sessionId} failed (HTTP ${res.status}). Try again.`);
|
|
89
|
+
}
|
|
90
|
+
const body = await res.text();
|
|
91
|
+
let payload;
|
|
92
|
+
try {
|
|
93
|
+
payload = JSON.parse(body);
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
throw new Error(`Session ${sessionId} export is not valid JSON. Try again.`);
|
|
97
|
+
}
|
|
98
|
+
if (exportPayloadSessionId(payload) === null) {
|
|
99
|
+
throw new Error(`Session ${sessionId} export is missing session metadata. Try again.`);
|
|
100
|
+
}
|
|
101
|
+
const tmpPath = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'megabrain-cloud-fork-')), `${sessionId}.json`);
|
|
102
|
+
fs.writeFileSync(tmpPath, body, { mode: 0o600 });
|
|
103
|
+
return tmpPath;
|
|
104
|
+
}
|
package/dist/device-auth.js
CHANGED
|
@@ -38,28 +38,40 @@ function tryOpenBrowser(url) {
|
|
|
38
38
|
}
|
|
39
39
|
}
|
|
40
40
|
const POLL_INTERVAL_MS = 2000;
|
|
41
|
-
|
|
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
|
-
|
|
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
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { spawn } from 'node:child_process';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
3
5
|
import { login } from './device-auth.js';
|
|
4
6
|
import { readCredentials, writeCredentials, clearCredentials } from './credentials.js';
|
|
5
7
|
import { writeOpenCodeConfig } from './opencode-config.js';
|
|
6
8
|
import { fetchGatewayModelIds } from './models.js';
|
|
7
9
|
import { ensureOpencodeBinary } from './opencode.js';
|
|
10
|
+
import { parseCloudForkArgs, isValidSessionId, downloadSessionExport } from './cloud-fork.js';
|
|
11
|
+
import { runScience, runScienceUrl } from './science/index.js';
|
|
8
12
|
async function signIn() {
|
|
9
13
|
console.log('Signing in to MegaBrain…');
|
|
10
14
|
const { token, userId, userEmail } = await login();
|
|
@@ -53,6 +57,31 @@ function runOpenCode(args) {
|
|
|
53
57
|
child.on('exit', code => resolve(code ?? 0));
|
|
54
58
|
});
|
|
55
59
|
}
|
|
60
|
+
/**
|
|
61
|
+
* Forks a cloud session locally: downloads the session export from the
|
|
62
|
+
* MegaBrain session-ingest service, imports it into OpenCode, then launches
|
|
63
|
+
* OpenCode on a fork of the imported session.
|
|
64
|
+
*/
|
|
65
|
+
async function runCloudFork(sessionId) {
|
|
66
|
+
const credentials = readCredentials();
|
|
67
|
+
if (!credentials) {
|
|
68
|
+
throw new Error('Not signed in. Run `megabrain login` and retry.');
|
|
69
|
+
}
|
|
70
|
+
console.log(`Downloading cloud session ${sessionId}…`);
|
|
71
|
+
const exportPath = await downloadSessionExport(sessionId, credentials.token);
|
|
72
|
+
try {
|
|
73
|
+
console.log('Importing session into OpenCode…');
|
|
74
|
+
const importExitCode = await runOpenCode(['import', exportPath]);
|
|
75
|
+
if (importExitCode !== 0) {
|
|
76
|
+
throw new Error(`Importing session ${sessionId} failed (opencode exit ${importExitCode}).`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
finally {
|
|
80
|
+
fs.rmSync(path.dirname(exportPath), { recursive: true, force: true });
|
|
81
|
+
}
|
|
82
|
+
console.log('Starting a fork of the imported session…\n');
|
|
83
|
+
return runOpenCode(['--session', sessionId, '--fork']);
|
|
84
|
+
}
|
|
56
85
|
async function main() {
|
|
57
86
|
const command = process.argv[2];
|
|
58
87
|
switch (command) {
|
|
@@ -65,9 +94,27 @@ async function main() {
|
|
|
65
94
|
case 'whoami':
|
|
66
95
|
runWhoami();
|
|
67
96
|
return;
|
|
97
|
+
case 'science':
|
|
98
|
+
if (process.argv[3] === 'url') {
|
|
99
|
+
await runScienceUrl();
|
|
100
|
+
}
|
|
101
|
+
else {
|
|
102
|
+
await runScience();
|
|
103
|
+
}
|
|
104
|
+
return;
|
|
68
105
|
default: {
|
|
106
|
+
const args = process.argv.slice(2);
|
|
107
|
+
const { cloudFork, sessionId } = parseCloudForkArgs(args);
|
|
108
|
+
if (cloudFork) {
|
|
109
|
+
if (!sessionId || !isValidSessionId(sessionId)) {
|
|
110
|
+
throw new Error('Usage: megabrain --session <ses_…> --cloud-fork (a valid cloud session id is required).');
|
|
111
|
+
}
|
|
112
|
+
await ensureSignedIn();
|
|
113
|
+
process.exitCode = await runCloudFork(sessionId);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
69
116
|
await ensureSignedIn();
|
|
70
|
-
const exitCode = await runOpenCode(
|
|
117
|
+
const exitCode = await runOpenCode(args);
|
|
71
118
|
process.exitCode = exitCode;
|
|
72
119
|
}
|
|
73
120
|
}
|
|
@@ -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
|
+
}
|