@vashp2029/glovebox 0.0.0-stage → 0.3.0

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.
@@ -0,0 +1,110 @@
1
+ // Host side of a session: owns one kernel process and serializes calls to it.
2
+ // The kernel (and every binding in it) lives until reset() or the host exits.
3
+ // There is no idle teardown here; the CLI daemon applies its own idle limit.
4
+ import { spawn } from "node:child_process";
5
+ import path from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+
8
+ const kernelPath = path.join(path.dirname(fileURLToPath(import.meta.url)), "kernel.mjs");
9
+ const ABORT_GRACE_MS = 5000;
10
+
11
+ export class Session {
12
+ constructor({ client = "An agent", imageMode = "inline" } = {}) {
13
+ this.client = client;
14
+ this.imageMode = imageMode;
15
+ this.kernel = null;
16
+ this.pending = new Map();
17
+ this.nextId = 1;
18
+ this.lastUsed = Date.now();
19
+ }
20
+
21
+ #start() {
22
+ const child = spawn(process.execPath, [kernelPath], {
23
+ stdio: ["pipe", "pipe", "pipe"],
24
+ env: { ...process.env, GLOVEBOX_CLIENT: this.client, GLOVEBOX_IMAGE_MODE: this.imageMode },
25
+ });
26
+ let buffer = "";
27
+ child.stdout.setEncoding("utf8");
28
+ child.stdout.on("data", (chunk) => {
29
+ buffer += chunk;
30
+ let newline;
31
+ while ((newline = buffer.indexOf("\n")) >= 0) {
32
+ const line = buffer.slice(0, newline);
33
+ buffer = buffer.slice(newline + 1);
34
+ if (!line) continue;
35
+ let message;
36
+ try {
37
+ message = JSON.parse(line);
38
+ } catch {
39
+ continue;
40
+ }
41
+ const waiter = this.pending.get(message.id);
42
+ if (waiter) {
43
+ this.pending.delete(message.id);
44
+ waiter.resolve(message);
45
+ }
46
+ }
47
+ });
48
+ let stderr = "";
49
+ child.stderr.on("data", (chunk) => {
50
+ stderr = (stderr + chunk).slice(-4000);
51
+ if (process.env.GLOVEBOX_DEBUG) process.stderr.write(chunk);
52
+ });
53
+ child.on("exit", (code, signal) => {
54
+ if (this.kernel === child) this.kernel = null;
55
+ const why = stderr.trim().split("\n").slice(-3).join("\n");
56
+ for (const [id, waiter] of this.pending) {
57
+ if (waiter.child !== child) continue;
58
+ this.pending.delete(id);
59
+ waiter.resolve({
60
+ ok: false,
61
+ content: [{ type: "text", text: `The session process exited (${signal ?? code}); bindings were lost.${why ? `\n${why}` : ""}` }],
62
+ });
63
+ }
64
+ });
65
+ this.kernel = child;
66
+ return child;
67
+ }
68
+
69
+ async run(code, { title, timeoutMs = 120_000 } = {}) {
70
+ this.lastUsed = Date.now();
71
+ const child = this.kernel ?? this.#start();
72
+ const id = this.nextId++;
73
+ const reply = new Promise((resolve) => this.pending.set(id, { resolve, child }));
74
+ child.stdin.write(`${JSON.stringify({ id, op: "eval", code, title })}\n`);
75
+ let timer;
76
+ let killer;
77
+ const timeout = new Promise((resolve) => {
78
+ timer = setTimeout(() => {
79
+ // Ask the script to stop at its next action; kill it if it won't.
80
+ child.stdin.write(`${JSON.stringify({ op: "abort", reason: `Stopped: the script ran longer than ${timeoutMs} ms.` })}\n`);
81
+ killer = setTimeout(() => {
82
+ if (this.kernel === child) this.kernel = null;
83
+ child.kill("SIGKILL");
84
+ resolve({
85
+ ok: false,
86
+ content: [{ type: "text", text: `The script ran longer than ${timeoutMs} ms and did not stop, so the session was restarted. Bindings were lost.` }],
87
+ });
88
+ }, ABORT_GRACE_MS);
89
+ }, timeoutMs);
90
+ });
91
+ try {
92
+ return await Promise.race([reply, timeout]);
93
+ } finally {
94
+ clearTimeout(timer);
95
+ clearTimeout(killer);
96
+ this.pending.delete(id);
97
+ this.lastUsed = Date.now();
98
+ }
99
+ }
100
+
101
+ reset() {
102
+ const child = this.kernel;
103
+ this.kernel = null;
104
+ if (child) child.kill("SIGTERM");
105
+ }
106
+
107
+ close() {
108
+ this.reset();
109
+ }
110
+ }
package/src/smoke.mjs ADDED
@@ -0,0 +1,160 @@
1
+ // `glovebox smoke`: proves the loop end to end on Calculator through the same
2
+ // MCP server an agent would use. It checks that a batched call lands, that a
3
+ // binding survives to the next call, that reads diff, and that js_reset drops
4
+ // the binding. Nothing here is mocked.
5
+ import { execFileSync, spawn } from "node:child_process";
6
+ import fs from "node:fs";
7
+ import os from "node:os";
8
+ import path from "node:path";
9
+ import { fileURLToPath } from "node:url";
10
+
11
+ const cli = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "bin", "glovebox.mjs");
12
+
13
+ function client() {
14
+ const child = spawn(process.execPath, [cli, "mcp"], { stdio: ["pipe", "pipe", "inherit"] });
15
+ const pending = new Map();
16
+ let nextId = 1;
17
+ let buffer = "";
18
+ child.stdout.setEncoding("utf8");
19
+ child.stdout.on("data", (chunk) => {
20
+ buffer += chunk;
21
+ let newline;
22
+ while ((newline = buffer.indexOf("\n")) >= 0) {
23
+ const message = JSON.parse(buffer.slice(0, newline));
24
+ buffer = buffer.slice(newline + 1);
25
+ pending.get(message.id)?.(message);
26
+ pending.delete(message.id);
27
+ }
28
+ });
29
+ const request = (method, params) =>
30
+ new Promise((resolve) => {
31
+ const id = nextId++;
32
+ pending.set(id, resolve);
33
+ child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", id, method, params })}\n`);
34
+ });
35
+ const js = async (code) => {
36
+ const reply = await request("tools/call", { name: "js", arguments: { code, title: "glovebox smoke" } });
37
+ const text = (reply.result?.content ?? []).filter((c) => c.type === "text").map((c) => c.text).join("\n");
38
+ return { text, isError: reply.result?.isError ?? true, images: (reply.result?.content ?? []).filter((c) => c.type === "image") };
39
+ };
40
+ return { request, js, close: () => child.stdin.end() };
41
+ }
42
+
43
+ // The frontmost app's name, so the smoke test can prove input stayed in the background.
44
+ function frontmostApp() {
45
+ if (process.platform !== "darwin") return null;
46
+ try {
47
+ return execFileSync("osascript", ["-l", "JavaScript", "-e", 'ObjC.import("AppKit"); $.NSWorkspace.sharedWorkspace.frontmostApplication.localizedName.js']).toString().trim();
48
+ } catch {
49
+ return null;
50
+ }
51
+ }
52
+
53
+ export async function runSmoke({ screenshot = true, full = false } = {}) {
54
+ const frontBefore = frontmostApp();
55
+ const mcp = client();
56
+ const results = [];
57
+ const step = (name, pass, detail) => {
58
+ results.push(pass);
59
+ process.stdout.write(`${pass ? "PASS" : "FAIL"} ${name}${detail ? `\n ${detail.split("\n").join("\n ")}` : ""}\n`);
60
+ };
61
+ try {
62
+ const init = await mcp.request("initialize", {
63
+ protocolVersion: "2025-11-25",
64
+ capabilities: {},
65
+ clientInfo: { name: "glovebox smoke", version: "1" },
66
+ });
67
+ const list = await mcp.request("tools/list", {});
68
+ step("MCP handshake", init.result?.serverInfo?.name === "glovebox" && list.result?.tools?.length === 2);
69
+
70
+ // One call: bind the app, several actions, one read.
71
+ const first = await mcp.js(`
72
+ const calc = await gb.getApp("Calculator");
73
+ await calc.pressKey("Escape");
74
+ await calc.typeText("6*7=");
75
+ await calc.getAXState();`);
76
+ step("batched call types 6*7= and reads 42", !first.isError && /value="42"/.test(first.text), first.isError ? first.text : undefined);
77
+
78
+ // Next call: the binding is still there; click by element index; read is a diff.
79
+ const second = await mcp.js(`
80
+ const tree = await calc.getAXState({ emit: false, disableDiffing: true });
81
+ const idx = (label) => Number(tree.match(new RegExp("^\\\\s*(\\\\d+) button \\"" + label + "\\"", "m"))[1]);
82
+ await calc.click(idx("All Clear"));
83
+ for (const label of ["7", "Add", "1", "Equals"]) await calc.click(idx(label));
84
+ await calc.getAXState();`);
85
+ step("binding persists; index clicks compute 7+1=8; read is a diff", !second.isError && /value="8"/.test(second.text) && /changes since last read/.test(second.text), second.isError ? second.text : undefined);
86
+
87
+ // Pixel click on the same space a screenshot uses.
88
+ const third = await mcp.js(`
89
+ const tree2 = await calc.getAXState({ emit: false, disableDiffing: true });
90
+ const at = (label) => Number(tree2.match(new RegExp("^\\\\s*(\\\\d+) button \\"" + label + "\\"", "m"))[1]);
91
+ await calc.click(at("All Clear"));
92
+ const [x, y] = await calc.center(at("9"));
93
+ await calc.click([x, y]);
94
+ await calc.getAXState();`);
95
+ step("pixel click [x, y] lands on the 9 key", !third.isError && /value="9"/.test(third.text), third.isError ? third.text : undefined);
96
+
97
+ if (screenshot) {
98
+ const shot = await mcp.js(`const s = await calc.getScreenshot(); s.width + "x" + s.height + " vs view " + calc.view.width + "x" + calc.view.height`);
99
+ const match = shot.text.match(/(\d+)x(\d+) vs view (\d+)x(\d+)/);
100
+ step(
101
+ "screenshot is an image in the same pixel space as clicks",
102
+ !shot.isError && shot.images.length === 1 && match && match[1] === match[3] && match[2] === match[4],
103
+ shot.isError ? shot.text : undefined,
104
+ );
105
+ }
106
+
107
+ await mcp.request("tools/call", { name: "js_reset", arguments: {} });
108
+ const after = await mcp.js("typeof calc");
109
+ step("js_reset drops the binding", !after.isError && after.text.trim() === "undefined", after.text);
110
+
111
+ if (full) {
112
+ // Raw mouse input in a background text editor: a click places the caret,
113
+ // a drag selects, a scroll moves the view. Only real mouse events do these.
114
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "glovebox-smoke-"));
115
+ const short = path.join(dir, "glovebox-smoke.txt");
116
+ const long = path.join(dir, "glovebox-smoke-long.txt");
117
+ fs.writeFileSync(short, "AAAA\nBBBB\nCCCC\n");
118
+ fs.writeFileSync(long, Array.from({ length: 300 }, (_, i) => `line ${i} ${"x".repeat(40)}`).join("\n"));
119
+ execFileSync("open", ["-g", "-a", "TextEdit", short, long]);
120
+ await new Promise((r) => setTimeout(r, 2500));
121
+ const te = await mcp.js(`
122
+ const te = await gb.getApp("TextEdit");
123
+ const wins = await te.windows();
124
+ const shortWin = wins.find((w) => w.title.startsWith("glovebox-smoke.txt"));
125
+ const longWin = wins.find((w) => w.title.startsWith("glovebox-smoke-long"));
126
+ const area = async (i) => (await te.getAXState({ window: i, emit: false, disableDiffing: true, frames: true })).match(/text area value="([^"]*)".*@(\\d+),(\\d+)/);
127
+ let a = await area(shortWin.index);
128
+ const [ax, ay] = [Number(a[2]), Number(a[3])];
129
+ await te.click([ax + 8, ay + 10]);
130
+ await te.typeText("Y");
131
+ const afterClick = (await area(shortWin.index))[1];
132
+ await te.drag([ax + 8, ay + 10], [ax + 300, ay + 10]);
133
+ await te.typeText("Q");
134
+ const afterDrag = (await area(shortWin.index))[1];
135
+ const bars = async () => [...(await te.getAXState({ window: longWin.index, emit: false, disableDiffing: true })).matchAll(/scroll bar value="([^"]+)"/g)].map((m) => m[1]).join(",");
136
+ const before = await bars();
137
+ await te.scroll([300, 300], 15);
138
+ const afterScroll = await bars();
139
+ JSON.stringify({ afterClick, afterDrag, before, afterScroll, route: te.lastRoute });`);
140
+ let r = {};
141
+ try {
142
+ r = JSON.parse(te.text.trim().split("\n").at(-1));
143
+ } catch {}
144
+ step("background click places the caret (TextEdit)", !te.isError && /^Y/.test(r.afterClick ?? ""), te.isError ? te.text : r.afterClick);
145
+ step("background drag selects text (TextEdit)", !te.isError && /^Q /.test(r.afterDrag ?? ""), te.isError ? undefined : r.afterDrag);
146
+ step("background scroll moves the view (TextEdit)", !te.isError && r.before !== r.afterScroll, te.isError ? undefined : `${r.before} -> ${r.afterScroll}`);
147
+ process.stdout.write(` (Two test documents are open in TextEdit; close them without saving: ${dir})\n`);
148
+ }
149
+
150
+ const frontAfter = frontmostApp();
151
+ if (frontBefore) {
152
+ step("input stayed in the background (frontmost app unchanged)", frontBefore === frontAfter, `${frontBefore} -> ${frontAfter}`);
153
+ }
154
+ } finally {
155
+ mcp.close();
156
+ }
157
+ const passed = results.every(Boolean);
158
+ process.stdout.write(passed ? "\nAll smoke checks passed.\n" : "\nSome smoke checks failed.\n");
159
+ return passed;
160
+ }
@@ -0,0 +1,131 @@
1
+ // Rewrites one REPL cell so top-level bindings outlive the call.
2
+ //
3
+ // A cell runs as `var <names>; <functions>; (async () => { <body> })()` inside a
4
+ // persistent vm context. Top-level `const`/`let`/`class` become assignments to
5
+ // context-level `var`s, so they persist and can be redeclared by a later cell.
6
+ // The last expression statement becomes the cell's return value.
7
+ import * as acorn from "acorn";
8
+
9
+ const PARSE_OPTIONS = {
10
+ ecmaVersion: "latest",
11
+ sourceType: "script",
12
+ allowAwaitOutsideFunction: true,
13
+ allowReturnOutsideFunction: true,
14
+ allowHashBang: true,
15
+ locations: true,
16
+ };
17
+
18
+ function patternNames(node, out) {
19
+ if (!node) return out;
20
+ switch (node.type) {
21
+ case "Identifier":
22
+ out.push(node.name);
23
+ break;
24
+ case "ObjectPattern":
25
+ for (const prop of node.properties) {
26
+ patternNames(prop.type === "RestElement" ? prop.argument : prop.value, out);
27
+ }
28
+ break;
29
+ case "ArrayPattern":
30
+ for (const element of node.elements) patternNames(element, out);
31
+ break;
32
+ case "RestElement":
33
+ patternNames(node.argument, out);
34
+ break;
35
+ case "AssignmentPattern":
36
+ patternNames(node.left, out);
37
+ break;
38
+ }
39
+ return out;
40
+ }
41
+
42
+ function importToAssignments(node, src) {
43
+ const source = src.slice(node.source.start, node.source.end);
44
+ const names = [];
45
+ const parts = [];
46
+ const tmp = `__gb_import_${node.start}`;
47
+ parts.push(`const ${tmp} = await import(${source});`);
48
+ for (const spec of node.specifiers) {
49
+ names.push(spec.local.name);
50
+ if (spec.type === "ImportDefaultSpecifier") {
51
+ parts.push(`${spec.local.name} = ${tmp}.default;`);
52
+ } else if (spec.type === "ImportNamespaceSpecifier") {
53
+ parts.push(`${spec.local.name} = ${tmp};`);
54
+ } else {
55
+ const imported =
56
+ spec.imported.type === "Literal" ? JSON.stringify(spec.imported.value) : JSON.stringify(spec.imported.name);
57
+ parts.push(`${spec.local.name} = ${tmp}[${imported}];`);
58
+ }
59
+ }
60
+ return { names, code: parts.join(" ") };
61
+ }
62
+
63
+ export class CellSyntaxError extends Error {
64
+ constructor(error) {
65
+ const loc = error.loc ? ` (line ${error.loc.line}, column ${error.loc.column + 1})` : "";
66
+ super(`SyntaxError: ${error.message.replace(/\s*\(\d+:\d+\)$/, "")}${loc}`);
67
+ this.name = "SyntaxError";
68
+ }
69
+ }
70
+
71
+ export function transformCell(src) {
72
+ let ast;
73
+ try {
74
+ ast = acorn.parse(src, PARSE_OPTIONS);
75
+ } catch (error) {
76
+ throw new CellSyntaxError(error);
77
+ }
78
+
79
+ const hoisted = new Set();
80
+ const prelude = [];
81
+ const edits = []; // [start, end, replacement], non-overlapping, top-level only
82
+
83
+ for (const node of ast.body) {
84
+ if (node.type === "VariableDeclaration") {
85
+ const assigns = [];
86
+ for (const decl of node.declarations) {
87
+ for (const name of patternNames(decl.id, [])) hoisted.add(name);
88
+ if (decl.init) {
89
+ const left = src.slice(decl.id.start, decl.id.end);
90
+ const right = src.slice(decl.init.start, decl.init.end);
91
+ assigns.push(`(${left} = ${right})`);
92
+ } else if (node.kind !== "var") {
93
+ assigns.push(`(${src.slice(decl.id.start, decl.id.end)} = undefined)`);
94
+ }
95
+ }
96
+ edits.push([node.start, node.end, assigns.length ? `${assigns.join(", ")};` : ";"]);
97
+ } else if (node.type === "FunctionDeclaration") {
98
+ // Functions see only context-level bindings, so they can live outside the IIFE.
99
+ prelude.push(src.slice(node.start, node.end));
100
+ edits.push([node.start, node.end, ""]);
101
+ } else if (node.type === "ClassDeclaration") {
102
+ hoisted.add(node.id.name);
103
+ edits.push([node.start, node.end, `${node.id.name} = ${src.slice(node.start, node.end)};`]);
104
+ } else if (node.type === "ImportDeclaration") {
105
+ const { names, code } = importToAssignments(node, src);
106
+ for (const name of names) hoisted.add(name);
107
+ edits.push([node.start, node.end, code]);
108
+ } else if (node.type === "ExportNamedDeclaration" || node.type === "ExportDefaultDeclaration") {
109
+ throw new CellSyntaxError({ message: "export is not supported in a cell", loc: node.loc.start });
110
+ }
111
+ }
112
+
113
+ const last = ast.body.at(-1);
114
+ if (last && last.type === "ExpressionStatement") {
115
+ const expr = src.slice(last.expression.start, last.expression.end);
116
+ edits.push([last.start, last.end, `return (${expr});`]);
117
+ }
118
+
119
+ edits.sort((a, b) => a[0] - b[0]);
120
+ let body = "";
121
+ let cursor = 0;
122
+ for (const [start, end, replacement] of edits) {
123
+ body += src.slice(cursor, start) + replacement;
124
+ cursor = end;
125
+ }
126
+ body += src.slice(cursor);
127
+
128
+ const decl = hoisted.size ? `var ${[...hoisted].join(", ")};\n` : "";
129
+ // Keep the user's code on its own lines so stack line numbers stay close.
130
+ return `${decl}${prelude.join("\n")}\n(async () => {\n${body}\n})()`;
131
+ }