march-cli 0.1.28 → 0.1.29
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/package.json +1 -1
- package/src/agent/runner.mjs +1 -1
- package/src/agent/runtime/runner-runtime-host.mjs +1 -0
- package/src/agent/session/session-options.mjs +2 -1
- package/src/agent/tools.mjs +3 -1
- package/src/browser/cli/command.mjs +61 -0
- package/src/browser/cli/open-url.mjs +21 -0
- package/src/browser/client/http.mjs +18 -0
- package/src/browser/client/lifecycle.mjs +57 -0
- package/src/browser/client/rpc.mjs +8 -0
- package/src/browser/client/state.mjs +35 -0
- package/src/browser/daemon/constants.mjs +3 -0
- package/src/browser/daemon/entry.mjs +28 -0
- package/src/browser/daemon/server.mjs +146 -0
- package/src/browser/extension/background.js +225 -0
- package/src/browser/extension/errors.js +19 -0
- package/src/browser/extension/execute-code.js +53 -0
- package/src/browser/extension/manifest.json +15 -0
- package/src/browser/extension-install.mjs +21 -0
- package/src/browser/tools/index.mjs +89 -0
- package/src/cli/args.mjs +4 -1
- package/src/main.mjs +14 -10
- package/src/memory/root.mjs +7 -0
package/package.json
CHANGED
package/src/agent/runner.mjs
CHANGED
|
@@ -81,7 +81,7 @@ export async function createRunner({ cwd, modelId = null, provider = null, provi
|
|
|
81
81
|
});
|
|
82
82
|
} else {
|
|
83
83
|
const sessionOptions = resolveRunnerSessionOptions({
|
|
84
|
-
cwd, provider, modelId, modelRegistry, engine, ui: runtimeUi,
|
|
84
|
+
cwd, stateRoot, provider, modelId, modelRegistry, engine, ui: runtimeUi,
|
|
85
85
|
memoryTools, shellRuntime, lspService, mcpTools, webTools, permissionController,
|
|
86
86
|
authStorage: resolvedAuth, projectMarchDir,
|
|
87
87
|
getCurrentModel: () => sessionBinding.get()?.model ?? selectedModel,
|
|
@@ -17,6 +17,7 @@ export function resolveRunnerSessionOptions({
|
|
|
17
17
|
permissionController = null,
|
|
18
18
|
authStorage = null,
|
|
19
19
|
projectMarchDir = null,
|
|
20
|
+
stateRoot = null,
|
|
20
21
|
getCurrentModel = null,
|
|
21
22
|
}) {
|
|
22
23
|
if (engine.cwd !== cwd) {
|
|
@@ -29,7 +30,7 @@ export function resolveRunnerSessionOptions({
|
|
|
29
30
|
?? (provider && modelId ? getModel(provider, modelId) : null);
|
|
30
31
|
if (!model) throw new Error(`Model not found: ${provider}/${modelId}`);
|
|
31
32
|
|
|
32
|
-
const customTools = createMarchCustomTools({ cwd, engine, ui, memoryTools, shellRuntime, lspService, mcpTools, webTools, permissionController, authStorage, projectMarchDir, getCurrentModel: () => getCurrentModel?.() ?? model });
|
|
33
|
+
const customTools = createMarchCustomTools({ cwd, engine, ui, memoryTools, shellRuntime, lspService, mcpTools, webTools, permissionController, authStorage, projectMarchDir, stateRoot, getCurrentModel: () => getCurrentModel?.() ?? model });
|
|
33
34
|
const customToolNames = customTools.map((tool) => tool.name);
|
|
34
35
|
const tools = [
|
|
35
36
|
...customToolNames.filter((name) => name === "read"),
|
package/src/agent/tools.mjs
CHANGED
|
@@ -9,8 +9,9 @@ import { toolText } from "./tool-result.mjs";
|
|
|
9
9
|
import { createShellTools } from "../shell/tools.mjs";
|
|
10
10
|
import { initImageGen } from "../image-gen/index.mjs";
|
|
11
11
|
import { createSuperGrokTool } from "../supergrok/tool.mjs";
|
|
12
|
+
import { createBrowserTools } from "../browser/tools/index.mjs";
|
|
12
13
|
|
|
13
|
-
export function createMarchCustomTools({ cwd, engine, ui, memoryTools = [], shellRuntime = null, lspService = null, mcpTools = [], webTools = [], permissionController = null, authStorage = null, projectMarchDir = null, getCurrentModel = null }) {
|
|
14
|
+
export function createMarchCustomTools({ cwd, engine, ui, memoryTools = [], shellRuntime = null, lspService = null, mcpTools = [], webTools = [], permissionController = null, authStorage = null, projectMarchDir = null, stateRoot = null, getCurrentModel = null }) {
|
|
14
15
|
const commandExecTool = createCommandExecTool({ cwd });
|
|
15
16
|
const contextStatsTool = createContextStatsTool({ engine });
|
|
16
17
|
const editFileTool = createEditFileTool({ engine, ui, lspService });
|
|
@@ -31,6 +32,7 @@ export function createMarchCustomTools({ cwd, engine, ui, memoryTools = [], shel
|
|
|
31
32
|
...memoryTools,
|
|
32
33
|
...mcpTools,
|
|
33
34
|
...webTools,
|
|
35
|
+
...createBrowserTools({ stateRoot }),
|
|
34
36
|
...(authStorage ? [createSuperGrokTool({ authStorage, projectMarchDir })] : []),
|
|
35
37
|
...(authStorage ? initImageGen({ authStorage, projectMarchDir }) : []),
|
|
36
38
|
];
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { ensureBrowserDaemon, stopBrowserDaemon } from "../client/lifecycle.mjs";
|
|
4
|
+
import { readBrowserDaemonState } from "../client/state.mjs";
|
|
5
|
+
import { requestBrowserDaemon } from "../client/http.mjs";
|
|
6
|
+
import { installedBrowserExtensionPath, syncBrowserExtensionInstall } from "../extension-install.mjs";
|
|
7
|
+
import { openBrowserUrl } from "./open-url.mjs";
|
|
8
|
+
|
|
9
|
+
export async function runBrowserCommand(args, { stateRoot = join(homedir(), ".march") } = {}) {
|
|
10
|
+
const subcommand = args.command.args[0] ?? "status";
|
|
11
|
+
if (subcommand === "install") return await installBrowser({ stateRoot });
|
|
12
|
+
if (subcommand === "status") return await printStatus({ stateRoot });
|
|
13
|
+
if (subcommand === "restart") return await restartBrowserDaemon({ stateRoot });
|
|
14
|
+
if (subcommand === "daemon" && args.foreground) return await runForegroundDaemon({ stateRoot });
|
|
15
|
+
process.stderr.write("Usage: march browser install|status|restart\n");
|
|
16
|
+
return 1;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function installBrowser({ stateRoot }) {
|
|
20
|
+
const extensionPath = syncBrowserExtensionInstall(stateRoot);
|
|
21
|
+
const state = await ensureBrowserDaemon({ stateRoot });
|
|
22
|
+
await openBrowserUrl("chrome://extensions");
|
|
23
|
+
process.stdout.write(`March Browser developer install\n\n`);
|
|
24
|
+
process.stdout.write(`1. Chrome extensions page opened: chrome://extensions\n`);
|
|
25
|
+
process.stdout.write(`2. Enable Developer mode.\n`);
|
|
26
|
+
process.stdout.write(`3. Click Load unpacked.\n`);
|
|
27
|
+
process.stdout.write(`4. Select this folder:\n ${extensionPath}\n`);
|
|
28
|
+
process.stdout.write(`5. If the extension is already loaded, click its Reload button.\n\n`);
|
|
29
|
+
process.stdout.write(`Daemon: ${state.url}\n`);
|
|
30
|
+
process.stdout.write(`Extension WebSocket: ${state.wsUrl}\n`);
|
|
31
|
+
return await printStatus({ stateRoot });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function printStatus({ stateRoot }) {
|
|
35
|
+
const state = readBrowserDaemonState(stateRoot);
|
|
36
|
+
try {
|
|
37
|
+
const status = await requestBrowserDaemon(state.url, "/status", null, { timeoutMs: 800 });
|
|
38
|
+
process.stdout.write(`Browser daemon: running pid=${status.pid}\n`);
|
|
39
|
+
process.stdout.write(`Browser extension: ${status.extensionConnected ? "connected" : "not connected"}\n`);
|
|
40
|
+
process.stdout.write(`Extension path: ${installedBrowserExtensionPath(stateRoot)}\n`);
|
|
41
|
+
return 0;
|
|
42
|
+
} catch {
|
|
43
|
+
process.stdout.write("Browser daemon: not running\n");
|
|
44
|
+
process.stdout.write(`Extension path: ${installedBrowserExtensionPath(stateRoot)}\n`);
|
|
45
|
+
return 0;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function restartBrowserDaemon({ stateRoot }) {
|
|
50
|
+
await stopBrowserDaemon({ stateRoot });
|
|
51
|
+
await ensureBrowserDaemon({ stateRoot });
|
|
52
|
+
return await printStatus({ stateRoot });
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function runForegroundDaemon({ stateRoot }) {
|
|
56
|
+
const { createBrowserDaemonServer } = await import("../daemon/server.mjs");
|
|
57
|
+
const server = createBrowserDaemonServer({ stateRoot });
|
|
58
|
+
await server.start();
|
|
59
|
+
process.stdout.write(`Browser daemon foreground: ${readBrowserDaemonState(stateRoot).url}\n`);
|
|
60
|
+
return new Promise(() => {});
|
|
61
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
export function openBrowserUrl(url) {
|
|
4
|
+
return new Promise((resolve, reject) => {
|
|
5
|
+
const { command, args } = openUrlCommand(url);
|
|
6
|
+
const child = spawn(command, args, { detached: true, stdio: "ignore", windowsHide: true });
|
|
7
|
+
child.once("error", reject);
|
|
8
|
+
child.once("spawn", () => {
|
|
9
|
+
child.unref();
|
|
10
|
+
resolve();
|
|
11
|
+
});
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function openUrlCommand(url) {
|
|
16
|
+
if (process.platform === "win32") {
|
|
17
|
+
return { command: "powershell.exe", args: ["-NoProfile", "-Command", "Start-Process $args[0]", url] };
|
|
18
|
+
}
|
|
19
|
+
if (process.platform === "darwin") return { command: "open", args: [url] };
|
|
20
|
+
return { command: "xdg-open", args: [url] };
|
|
21
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export async function requestBrowserDaemon(url, path, body = null, { timeoutMs = 5000 } = {}) {
|
|
2
|
+
const controller = new AbortController();
|
|
3
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
4
|
+
try {
|
|
5
|
+
const response = await fetch(`${url}${path}`, {
|
|
6
|
+
method: body ? "POST" : "GET",
|
|
7
|
+
headers: body ? { "content-type": "application/json" } : undefined,
|
|
8
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
9
|
+
signal: controller.signal,
|
|
10
|
+
});
|
|
11
|
+
const text = await response.text();
|
|
12
|
+
const payload = text ? JSON.parse(text) : null;
|
|
13
|
+
if (!response.ok) throw new Error(payload?.error ?? `Browser daemon HTTP ${response.status}`);
|
|
14
|
+
return payload;
|
|
15
|
+
} finally {
|
|
16
|
+
clearTimeout(timer);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import { dirname, resolve } from "node:path";
|
|
4
|
+
import { readBrowserDaemonState, removeBrowserDaemonState } from "./state.mjs";
|
|
5
|
+
import { requestBrowserDaemon } from "./http.mjs";
|
|
6
|
+
|
|
7
|
+
export async function ensureBrowserDaemon({ stateRoot, quiet = true } = {}) {
|
|
8
|
+
const state = readBrowserDaemonState(stateRoot);
|
|
9
|
+
if (await pingBrowserDaemon(state.url)) return state;
|
|
10
|
+
|
|
11
|
+
removeBrowserDaemonState(stateRoot);
|
|
12
|
+
const child = spawn(process.execPath, [daemonEntryPath(), "--state-root", stateRoot], {
|
|
13
|
+
detached: true,
|
|
14
|
+
stdio: quiet ? "ignore" : "inherit",
|
|
15
|
+
windowsHide: true,
|
|
16
|
+
});
|
|
17
|
+
child.once("error", () => {});
|
|
18
|
+
child.unref();
|
|
19
|
+
|
|
20
|
+
const deadline = Date.now() + 4000;
|
|
21
|
+
let lastError = null;
|
|
22
|
+
while (Date.now() < deadline) {
|
|
23
|
+
await sleep(120);
|
|
24
|
+
try {
|
|
25
|
+
const next = readBrowserDaemonState(stateRoot);
|
|
26
|
+
if (await pingBrowserDaemon(next.url)) return next;
|
|
27
|
+
} catch (err) {
|
|
28
|
+
lastError = err;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
throw new Error(`Browser daemon did not start${lastError ? `: ${lastError.message}` : ""}`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function pingBrowserDaemon(url) {
|
|
35
|
+
try {
|
|
36
|
+
const status = await requestBrowserDaemon(url, "/status", null, { timeoutMs: 700 });
|
|
37
|
+
return Boolean(status?.ok);
|
|
38
|
+
} catch {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function stopBrowserDaemon({ stateRoot } = {}) {
|
|
44
|
+
const state = readBrowserDaemonState(stateRoot);
|
|
45
|
+
try {
|
|
46
|
+
await requestBrowserDaemon(state.url, "/shutdown", {}, { timeoutMs: 1500 });
|
|
47
|
+
} catch {}
|
|
48
|
+
removeBrowserDaemonState(stateRoot);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function daemonEntryPath() {
|
|
52
|
+
return resolve(dirname(fileURLToPath(import.meta.url)), "../daemon/entry.mjs");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function sleep(ms) {
|
|
56
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
57
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { ensureBrowserDaemon } from "./lifecycle.mjs";
|
|
2
|
+
import { requestBrowserDaemon } from "./http.mjs";
|
|
3
|
+
|
|
4
|
+
export async function callBrowserDaemon({ stateRoot, method, params = {}, timeoutMs = 30000 }) {
|
|
5
|
+
const state = await ensureBrowserDaemon({ stateRoot });
|
|
6
|
+
const response = await requestBrowserDaemon(state.url, "/rpc", { method, params, timeoutMs }, { timeoutMs: timeoutMs + 1000 });
|
|
7
|
+
return response.result;
|
|
8
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, rmSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { BROWSER_DAEMON_HOST, BROWSER_DAEMON_PORT, BROWSER_DAEMON_STATE_FILE } from "../daemon/constants.mjs";
|
|
4
|
+
|
|
5
|
+
export function browserDaemonStatePath(stateRoot) {
|
|
6
|
+
return join(stateRoot, BROWSER_DAEMON_STATE_FILE);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function defaultBrowserDaemonState() {
|
|
10
|
+
return {
|
|
11
|
+
pid: null,
|
|
12
|
+
url: `http://${BROWSER_DAEMON_HOST}:${BROWSER_DAEMON_PORT}`,
|
|
13
|
+
wsUrl: `ws://${BROWSER_DAEMON_HOST}:${BROWSER_DAEMON_PORT}/extension`,
|
|
14
|
+
startedAt: null,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function readBrowserDaemonState(stateRoot) {
|
|
19
|
+
const path = browserDaemonStatePath(stateRoot);
|
|
20
|
+
if (!existsSync(path)) return defaultBrowserDaemonState();
|
|
21
|
+
try {
|
|
22
|
+
return { ...defaultBrowserDaemonState(), ...JSON.parse(readFileSync(path, "utf8")) };
|
|
23
|
+
} catch {
|
|
24
|
+
return defaultBrowserDaemonState();
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function writeBrowserDaemonState(stateRoot, state) {
|
|
29
|
+
mkdirSync(stateRoot, { recursive: true });
|
|
30
|
+
writeFileSync(browserDaemonStatePath(stateRoot), JSON.stringify(state, null, 2));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function removeBrowserDaemonState(stateRoot) {
|
|
34
|
+
try { rmSync(browserDaemonStatePath(stateRoot), { force: true }); } catch {}
|
|
35
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { createBrowserDaemonServer } from "./server.mjs";
|
|
5
|
+
|
|
6
|
+
const args = parseArgs(process.argv.slice(2));
|
|
7
|
+
const stateRoot = args["state-root"] ?? join(homedir(), ".march");
|
|
8
|
+
const server = createBrowserDaemonServer({ stateRoot });
|
|
9
|
+
|
|
10
|
+
process.on("SIGTERM", () => server.shutdown().then(() => process.exit(0)));
|
|
11
|
+
process.on("SIGINT", () => server.shutdown().then(() => process.exit(0)));
|
|
12
|
+
|
|
13
|
+
try {
|
|
14
|
+
await server.start();
|
|
15
|
+
} catch (err) {
|
|
16
|
+
process.stderr.write(`Browser daemon failed: ${err.message}\n`);
|
|
17
|
+
process.exitCode = 1;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function parseArgs(argv) {
|
|
21
|
+
const out = {};
|
|
22
|
+
for (let i = 0; i < argv.length; i++) {
|
|
23
|
+
if (!argv[i].startsWith("--")) continue;
|
|
24
|
+
const key = argv[i].slice(2);
|
|
25
|
+
out[key] = argv[i + 1]?.startsWith("--") ? true : argv[++i];
|
|
26
|
+
}
|
|
27
|
+
return out;
|
|
28
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { createServer } from "node:http";
|
|
2
|
+
import WebSocket, { WebSocketServer } from "ws";
|
|
3
|
+
import { BROWSER_DAEMON_HOST, BROWSER_DAEMON_PORT } from "./constants.mjs";
|
|
4
|
+
import { writeBrowserDaemonState } from "../client/state.mjs";
|
|
5
|
+
|
|
6
|
+
export function createBrowserDaemonServer({ stateRoot, port = BROWSER_DAEMON_PORT } = {}) {
|
|
7
|
+
const bridge = createExtensionBridge();
|
|
8
|
+
const server = createServer((req, res) => handleHttp(req, res, bridge, () => shutdown()));
|
|
9
|
+
const wss = new WebSocketServer({ noServer: true });
|
|
10
|
+
|
|
11
|
+
server.on("upgrade", (req, socket, head) => {
|
|
12
|
+
if (new URL(req.url, "http://localhost").pathname !== "/extension") {
|
|
13
|
+
socket.destroy();
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
wss.handleUpgrade(req, socket, head, (ws) => bridge.attach(ws));
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
async function start() {
|
|
20
|
+
await new Promise((resolve, reject) => {
|
|
21
|
+
server.once("error", reject);
|
|
22
|
+
server.listen(port, BROWSER_DAEMON_HOST, resolve);
|
|
23
|
+
});
|
|
24
|
+
const address = server.address();
|
|
25
|
+
const actualPort = typeof address === "object" ? address.port : port;
|
|
26
|
+
writeBrowserDaemonState(stateRoot, {
|
|
27
|
+
pid: process.pid,
|
|
28
|
+
url: `http://${BROWSER_DAEMON_HOST}:${actualPort}`,
|
|
29
|
+
wsUrl: `ws://${BROWSER_DAEMON_HOST}:${actualPort}/extension`,
|
|
30
|
+
startedAt: Date.now(),
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function shutdown() {
|
|
35
|
+
bridge.close();
|
|
36
|
+
wss.close();
|
|
37
|
+
await new Promise((resolve) => server.close(resolve));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return { start, shutdown, bridge };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function createExtensionBridge() {
|
|
44
|
+
let socket = null;
|
|
45
|
+
const pending = new Map();
|
|
46
|
+
|
|
47
|
+
function attach(ws) {
|
|
48
|
+
if (socket && socket.readyState === WebSocket.OPEN) socket.close();
|
|
49
|
+
socket = ws;
|
|
50
|
+
ws.on("message", (data) => handleExtensionMessage(data));
|
|
51
|
+
ws.on("close", () => {
|
|
52
|
+
if (socket === ws) socket = null;
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function request(method, params = {}, timeoutMs = 30000) {
|
|
57
|
+
if (!socket || socket.readyState !== WebSocket.OPEN) throw new Error("Browser extension is not connected. Run: march browser install");
|
|
58
|
+
const id = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
|
|
59
|
+
const message = JSON.stringify({ id, method, params });
|
|
60
|
+
return await new Promise((resolve, reject) => {
|
|
61
|
+
const timer = setTimeout(() => {
|
|
62
|
+
pending.delete(id);
|
|
63
|
+
reject(new Error(`Browser extension request timed out: ${method}`));
|
|
64
|
+
}, timeoutMs);
|
|
65
|
+
pending.set(id, { resolve, reject, timer });
|
|
66
|
+
socket.send(message, (err) => {
|
|
67
|
+
if (!err) return;
|
|
68
|
+
clearTimeout(timer);
|
|
69
|
+
pending.delete(id);
|
|
70
|
+
reject(err);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function handleExtensionMessage(data) {
|
|
76
|
+
let msg;
|
|
77
|
+
try { msg = JSON.parse(String(data)); } catch { return; }
|
|
78
|
+
const entry = pending.get(msg.id);
|
|
79
|
+
if (!entry) return;
|
|
80
|
+
clearTimeout(entry.timer);
|
|
81
|
+
pending.delete(msg.id);
|
|
82
|
+
msg.ok === false ? entry.reject(new Error(formatExtensionError(msg.error))) : entry.resolve(msg.result);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function close() {
|
|
86
|
+
for (const [id, entry] of pending) {
|
|
87
|
+
clearTimeout(entry.timer);
|
|
88
|
+
entry.reject(new Error("Browser daemon is shutting down"));
|
|
89
|
+
pending.delete(id);
|
|
90
|
+
}
|
|
91
|
+
socket?.close();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return { attach, request, close, isConnected: () => Boolean(socket && socket.readyState === WebSocket.OPEN) };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function handleHttp(req, res, bridge, shutdown) {
|
|
98
|
+
try {
|
|
99
|
+
const path = new URL(req.url, "http://localhost").pathname;
|
|
100
|
+
if (req.method === "GET" && path === "/status") {
|
|
101
|
+
return sendJson(res, 200, { ok: true, pid: process.pid, extensionConnected: bridge.isConnected() });
|
|
102
|
+
}
|
|
103
|
+
if (req.method === "POST" && path === "/rpc") {
|
|
104
|
+
const body = await readJson(req);
|
|
105
|
+
const result = await bridge.request(body.method, body.params, body.timeoutMs);
|
|
106
|
+
return sendJson(res, 200, { ok: true, result });
|
|
107
|
+
}
|
|
108
|
+
if (req.method === "POST" && path === "/shutdown") {
|
|
109
|
+
sendJson(res, 200, { ok: true });
|
|
110
|
+
setTimeout(() => shutdown().then(() => process.exit(0)), 10);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
sendJson(res, 404, { ok: false, error: "Not found" });
|
|
114
|
+
} catch (err) {
|
|
115
|
+
sendJson(res, 500, { ok: false, error: err.message });
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function readJson(req) {
|
|
120
|
+
return new Promise((resolve, reject) => {
|
|
121
|
+
let body = "";
|
|
122
|
+
req.on("data", (chunk) => { body += chunk; });
|
|
123
|
+
req.on("end", () => {
|
|
124
|
+
try { resolve(body ? JSON.parse(body) : {}); } catch (err) { reject(err); }
|
|
125
|
+
});
|
|
126
|
+
req.on("error", reject);
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function sendJson(res, status, payload) {
|
|
131
|
+
res.writeHead(status, { "content-type": "application/json" });
|
|
132
|
+
res.end(JSON.stringify(payload));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function formatExtensionError(error) {
|
|
136
|
+
if (!error) return "Browser extension request failed";
|
|
137
|
+
if (typeof error === "string") return error;
|
|
138
|
+
if (typeof error.stack === "string" && error.stack) return error.stack;
|
|
139
|
+
if (typeof error.message === "string" && error.message) return error.message;
|
|
140
|
+
if (error.message && typeof error.message === "object") return safeStringify(error.message);
|
|
141
|
+
return safeStringify(error);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function safeStringify(value) {
|
|
145
|
+
try { return JSON.stringify(value); } catch { return String(value); }
|
|
146
|
+
}
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { buildExecCode } from "./execute-code.js";
|
|
2
|
+
import { serializeError } from "./errors.js";
|
|
3
|
+
|
|
4
|
+
const DAEMON_WS = "ws://127.0.0.1:4328/extension";
|
|
5
|
+
let socket = null;
|
|
6
|
+
let reconnectTimer = null;
|
|
7
|
+
let connecting = false;
|
|
8
|
+
|
|
9
|
+
startBridge();
|
|
10
|
+
chrome.runtime.onStartup.addListener(startBridge);
|
|
11
|
+
chrome.runtime.onInstalled.addListener(startBridge);
|
|
12
|
+
chrome.alarms.onAlarm.addListener((alarm) => {
|
|
13
|
+
if (alarm.name === "march-browser-reconnect") connect();
|
|
14
|
+
if (alarm.name === "march-browser-keepalive") keepAlive();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
function startBridge() {
|
|
18
|
+
connect();
|
|
19
|
+
chrome.alarms.create("march-browser-reconnect", { periodInMinutes: 0.5 });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function connect() {
|
|
23
|
+
if (connecting || socket?.readyState === WebSocket.OPEN || socket?.readyState === WebSocket.CONNECTING) return;
|
|
24
|
+
connecting = true;
|
|
25
|
+
try {
|
|
26
|
+
socket = new WebSocket(DAEMON_WS);
|
|
27
|
+
} catch {
|
|
28
|
+
connecting = false;
|
|
29
|
+
scheduleReconnect();
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
socket.onopen = () => {
|
|
33
|
+
connecting = false;
|
|
34
|
+
setBadge("on");
|
|
35
|
+
scheduleKeepAlive();
|
|
36
|
+
};
|
|
37
|
+
socket.onmessage = (event) => handleMessage(event.data);
|
|
38
|
+
socket.onclose = scheduleReconnect;
|
|
39
|
+
socket.onerror = () => socket?.close();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function keepAlive() {
|
|
43
|
+
if (socket?.readyState === WebSocket.OPEN) {
|
|
44
|
+
send({ type: "ping" });
|
|
45
|
+
scheduleKeepAlive();
|
|
46
|
+
} else {
|
|
47
|
+
scheduleReconnect();
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function scheduleKeepAlive() {
|
|
52
|
+
chrome.alarms.create("march-browser-keepalive", { delayInMinutes: 0.4 });
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function scheduleReconnect() {
|
|
56
|
+
connecting = false;
|
|
57
|
+
setBadge("off");
|
|
58
|
+
if (reconnectTimer) return;
|
|
59
|
+
reconnectTimer = setTimeout(() => {
|
|
60
|
+
reconnectTimer = null;
|
|
61
|
+
connect();
|
|
62
|
+
}, 1000);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function handleMessage(data) {
|
|
66
|
+
const request = JSON.parse(data);
|
|
67
|
+
if (!request.id) return;
|
|
68
|
+
try {
|
|
69
|
+
const result = await dispatch(request.method, request.params ?? {});
|
|
70
|
+
send({ id: request.id, ok: true, result });
|
|
71
|
+
} catch (err) {
|
|
72
|
+
send({ id: request.id, ok: false, error: serializeError(err) });
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function send(payload) {
|
|
77
|
+
if (socket?.readyState === WebSocket.OPEN) socket.send(JSON.stringify(payload));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function setBadge(state) {
|
|
81
|
+
chrome.action.setBadgeText({ text: state === "on" ? "ON" : "" });
|
|
82
|
+
chrome.action.setBadgeBackgroundColor({ color: "#16a34a" });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function dispatch(method, params) {
|
|
86
|
+
if (method === "tabs") return { tabs: (await chrome.tabs.query({})).map(formatTab) };
|
|
87
|
+
if (method === "open") return await openTab(params);
|
|
88
|
+
if (method === "read") return await readTab(params);
|
|
89
|
+
if (method === "script") return await runScript(params);
|
|
90
|
+
throw new Error(`Unknown browser method: ${method}`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function openTab(params) {
|
|
94
|
+
const action = params.action;
|
|
95
|
+
const tabId = parseTabId(params.tabId);
|
|
96
|
+
if (action === "new") return { tab: formatTab(await chrome.tabs.create({ url: requiredUrl(params.url), active: params.active ?? true })) };
|
|
97
|
+
if (!tabId) throw new Error(`${action} requires tabId`);
|
|
98
|
+
if (action === "navigate") return { tab: formatTab(await chrome.tabs.update(tabId, { url: requiredUrl(params.url), active: params.active })) };
|
|
99
|
+
if (action === "focus") return await focusTab(tabId);
|
|
100
|
+
if (action === "close") { await chrome.tabs.remove(tabId); return { closed: String(tabId) }; }
|
|
101
|
+
if (action === "reload") { await chrome.tabs.reload(tabId); return { reloaded: String(tabId) }; }
|
|
102
|
+
if (action === "back" || action === "forward") return await navHistory(tabId, action);
|
|
103
|
+
throw new Error(`Unknown open action: ${action}`);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function focusTab(tabId) {
|
|
107
|
+
const tab = await chrome.tabs.update(tabId, { active: true });
|
|
108
|
+
if (tab.windowId) await chrome.windows.update(tab.windowId, { focused: true });
|
|
109
|
+
return { tab: formatTab(tab) };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function navHistory(tabId, action) {
|
|
113
|
+
await executePageCode(tabId, action === "back" ? "history.back(); true" : "history.forward(); true");
|
|
114
|
+
return { tabId: String(tabId), action };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function readTab(params) {
|
|
118
|
+
const tabId = requireTabId(params.tabId, "read");
|
|
119
|
+
const include = params.include ?? { text: true, elements: true };
|
|
120
|
+
const page = await executePageCode(tabId, buildReadCode(include));
|
|
121
|
+
const tab = await chrome.tabs.get(tabId);
|
|
122
|
+
return { tab: formatTab(tab), page };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function runScript(params) {
|
|
126
|
+
const tabId = requireTabId(params.tabId, "script");
|
|
127
|
+
const result = await executePageCode(tabId, String(params.code ?? ""));
|
|
128
|
+
return { tabId: String(tabId), result };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function executePageCode(tabId, code) {
|
|
132
|
+
const wrapped = buildExecCode(code);
|
|
133
|
+
const injected = await executeViaScripting(tabId, wrapped).catch((err) => ({ ok: false, csp: true, error: serializeError(err) }));
|
|
134
|
+
if (injected?.ok) return injected.data;
|
|
135
|
+
if (!injected?.csp) throwErrorResult(injected);
|
|
136
|
+
const cdp = await executeViaCdp(tabId, wrapped).catch((err) => ({ ok: false, error: serializeError(err) }));
|
|
137
|
+
if (cdp?.ok) return cdp.data;
|
|
138
|
+
throwErrorResult(cdp);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function executeViaScripting(tabId, source) {
|
|
142
|
+
const [injection] = await chrome.scripting.executeScript({
|
|
143
|
+
target: { tabId },
|
|
144
|
+
world: "MAIN",
|
|
145
|
+
func: (script) => eval(script),
|
|
146
|
+
args: [source],
|
|
147
|
+
});
|
|
148
|
+
if (!injection) return { ok: false, error: { message: `No script injection result for tab ${tabId}` } };
|
|
149
|
+
if (injection.result == null) return { ok: false, csp: true, error: { message: "Script returned no value" } };
|
|
150
|
+
return injection.result;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function executeViaCdp(tabId, expression) {
|
|
154
|
+
const target = { tabId };
|
|
155
|
+
let attached = false;
|
|
156
|
+
try {
|
|
157
|
+
await chrome.debugger.attach(target, "1.3");
|
|
158
|
+
attached = true;
|
|
159
|
+
const response = await chrome.debugger.sendCommand(target, "Runtime.evaluate", {
|
|
160
|
+
expression,
|
|
161
|
+
awaitPromise: true,
|
|
162
|
+
returnByValue: true,
|
|
163
|
+
});
|
|
164
|
+
if (response.exceptionDetails) {
|
|
165
|
+
return { ok: false, error: { message: response.exceptionDetails.exception?.description || "CDP Runtime.evaluate failed" } };
|
|
166
|
+
}
|
|
167
|
+
return response.result?.value ?? { ok: true, data: undefined };
|
|
168
|
+
} finally {
|
|
169
|
+
if (attached) await chrome.debugger.detach(target).catch(() => {});
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function buildReadCode(include) {
|
|
174
|
+
return `return (() => {
|
|
175
|
+
const include = ${JSON.stringify(include)};
|
|
176
|
+
const page = { title: document.title, url: location.href };
|
|
177
|
+
if (include.text !== false) page.text = document.body?.innerText || "";
|
|
178
|
+
if (include.html) page.html = document.documentElement?.outerHTML || "";
|
|
179
|
+
if (include.elements !== false) page.elements = Array.from(document.querySelectorAll("a,button,input,textarea,select,[role=button],[role=link],[contenteditable=true]")).slice(0, 300).map((el, index) => {
|
|
180
|
+
const tag = el.tagName.toLowerCase();
|
|
181
|
+
const text = (el.innerText || el.value || el.getAttribute("aria-label") || "").trim().slice(0, 200);
|
|
182
|
+
return { index, tag, type: el.getAttribute("type") || undefined, role: el.getAttribute("role") || undefined, text, placeholder: el.getAttribute("placeholder") || undefined, href: el.href || undefined, selector: selectorFor(el) };
|
|
183
|
+
});
|
|
184
|
+
return page;
|
|
185
|
+
function selectorFor(el) {
|
|
186
|
+
if (el.id) return "#" + CSS.escape(el.id);
|
|
187
|
+
const name = el.getAttribute("name");
|
|
188
|
+
if (name) return tagName(el) + "[name=\\\"" + CSS.escape(name) + "\\\"]";
|
|
189
|
+
const parts = [];
|
|
190
|
+
for (let node = el; node && node.nodeType === Node.ELEMENT_NODE && parts.length < 4; node = node.parentElement) {
|
|
191
|
+
const tag = tagName(node);
|
|
192
|
+
const siblings = Array.from(node.parentElement?.children || []).filter((child) => child.tagName === node.tagName);
|
|
193
|
+
parts.unshift(siblings.length > 1 ? tag + ":nth-of-type(" + (siblings.indexOf(node) + 1) + ")" : tag);
|
|
194
|
+
}
|
|
195
|
+
return parts.join(" > ");
|
|
196
|
+
}
|
|
197
|
+
function tagName(el) { return el.tagName.toLowerCase(); }
|
|
198
|
+
})()`;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function throwErrorResult(result) {
|
|
202
|
+
const error = result?.error;
|
|
203
|
+
if (typeof error === "string") throw new Error(error);
|
|
204
|
+
throw new Error(error?.stack || error?.message || "Browser script failed");
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function formatTab(tab) {
|
|
208
|
+
return { id: String(tab.id), windowId: tab.windowId, active: tab.active, title: tab.title, url: tab.url, status: tab.status };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function requireTabId(value, action) {
|
|
212
|
+
const tabId = parseTabId(value);
|
|
213
|
+
if (!tabId) throw new Error(`${action} requires tabId`);
|
|
214
|
+
return tabId;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function parseTabId(value) {
|
|
218
|
+
const id = Number(value);
|
|
219
|
+
return Number.isFinite(id) ? id : null;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function requiredUrl(url) {
|
|
223
|
+
if (!url) throw new Error("url is required");
|
|
224
|
+
return url;
|
|
225
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export function serializeError(err) {
|
|
2
|
+
if (!err) return { message: "Unknown error" };
|
|
3
|
+
if (typeof err === "string") return { message: err };
|
|
4
|
+
const name = typeof err.name === "string" && err.name ? err.name : "Error";
|
|
5
|
+
const stack = typeof err.stack === "string" ? err.stack : "";
|
|
6
|
+
const message = readableErrorMessage(err);
|
|
7
|
+
return { name, message, stack };
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function readableErrorMessage(err) {
|
|
11
|
+
if (typeof err?.message === "string" && err.message && err.message !== "[object Object]") return err.message;
|
|
12
|
+
if (typeof err?.message === "object") return safeStringify(err.message);
|
|
13
|
+
const json = safeStringify(err);
|
|
14
|
+
return json === "{}" ? String(err) : json;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function safeStringify(value) {
|
|
18
|
+
try { return JSON.stringify(value); } catch { return String(value); }
|
|
19
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
export function buildExecCode(code) {
|
|
2
|
+
return `(async () => {
|
|
3
|
+
function smartResult(value) {
|
|
4
|
+
if (value == null || typeof value !== "object") return value;
|
|
5
|
+
try { if (value.window === value && value.document) return "[Window: " + (value.location?.href || "about:blank") + "]"; } catch {}
|
|
6
|
+
if (typeof Node !== "undefined" && value.nodeType === Node.ELEMENT_NODE) return value.outerHTML;
|
|
7
|
+
if (typeof NodeList !== "undefined" && (value instanceof NodeList || value instanceof HTMLCollection)) {
|
|
8
|
+
return Array.from(value).slice(0, 300).map((item) => item?.nodeType === Node.ELEMENT_NODE ? item.outerHTML : String(item));
|
|
9
|
+
}
|
|
10
|
+
try {
|
|
11
|
+
return JSON.parse(JSON.stringify(value, (_key, item) => {
|
|
12
|
+
if (item && typeof item === "object") {
|
|
13
|
+
if (typeof Node !== "undefined" && item.nodeType === Node.ELEMENT_NODE) return item.outerHTML;
|
|
14
|
+
try { if (item.window === item && item.document) return "[Window]"; } catch {}
|
|
15
|
+
}
|
|
16
|
+
return item;
|
|
17
|
+
}));
|
|
18
|
+
} catch (err) {
|
|
19
|
+
return "[Unserializable: " + (err?.message || String(err)) + "]";
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
function executable(source) {
|
|
23
|
+
const lines = source.split(/\\r?\\n/);
|
|
24
|
+
let index = lines.length - 1;
|
|
25
|
+
while (index >= 0 && !lines[index].trim()) index--;
|
|
26
|
+
if (index < 0) return source;
|
|
27
|
+
const last = lines[index].trim();
|
|
28
|
+
if (/^(return\\b|let\\b|const\\b|var\\b|if\\b|for\\b|while\\b|switch\\b|try\\b|throw\\b|class\\b|function\\b|async\\b|import\\b|export\\b|\\/\\/|})/.test(last)) return source;
|
|
29
|
+
lines[index] = lines[index].match(/^(\\s*)/)[1] + "return " + last;
|
|
30
|
+
return lines.join("\\n");
|
|
31
|
+
}
|
|
32
|
+
try {
|
|
33
|
+
const source = ${JSON.stringify(code)}.trim();
|
|
34
|
+
const AsyncFunction = Object.getPrototypeOf(async function(){}).constructor;
|
|
35
|
+
let value;
|
|
36
|
+
if (/^return\\b/.test(source) || /\\n\\s*return\\b/.test(source)) {
|
|
37
|
+
value = await (new AsyncFunction(source))();
|
|
38
|
+
} else {
|
|
39
|
+
try {
|
|
40
|
+
value = eval(source);
|
|
41
|
+
if (value && typeof value.then === "function") value = await value;
|
|
42
|
+
} catch (err) {
|
|
43
|
+
if (err instanceof SyntaxError) value = await (new AsyncFunction(executable(source)))();
|
|
44
|
+
else throw err;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return { ok: true, data: smartResult(value) };
|
|
48
|
+
} catch (err) {
|
|
49
|
+
const message = err?.message || String(err);
|
|
50
|
+
return { ok: false, csp: /unsafe-eval|Content Security Policy|Refused to evaluate/i.test(message), error: { name: err?.name || "Error", message, stack: err?.stack || "" } };
|
|
51
|
+
}
|
|
52
|
+
})()`;
|
|
53
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"manifest_version": 3,
|
|
3
|
+
"name": "March Browser Bridge",
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"description": "Connects the user's browser tabs to March CLI.",
|
|
6
|
+
"permissions": ["tabs", "scripting", "webNavigation", "storage", "alarms", "debugger"],
|
|
7
|
+
"host_permissions": ["<all_urls>"],
|
|
8
|
+
"background": {
|
|
9
|
+
"service_worker": "background.js",
|
|
10
|
+
"type": "module"
|
|
11
|
+
},
|
|
12
|
+
"action": {
|
|
13
|
+
"default_title": "March Browser Bridge"
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { cpSync, existsSync, rmSync } from "node:fs";
|
|
2
|
+
import { dirname, resolve } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
|
|
5
|
+
export function sourceBrowserExtensionPath() {
|
|
6
|
+
const path = resolve(dirname(fileURLToPath(import.meta.url)), "extension");
|
|
7
|
+
if (!existsSync(path)) throw new Error(`Browser extension not found: ${path}`);
|
|
8
|
+
return path;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function installedBrowserExtensionPath(stateRoot) {
|
|
12
|
+
return resolve(stateRoot, "browser-extension");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function syncBrowserExtensionInstall(stateRoot) {
|
|
16
|
+
const source = sourceBrowserExtensionPath();
|
|
17
|
+
const target = installedBrowserExtensionPath(stateRoot);
|
|
18
|
+
rmSync(target, { recursive: true, force: true });
|
|
19
|
+
cpSync(source, target, { recursive: true });
|
|
20
|
+
return target;
|
|
21
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { defineTool } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { Type } from "typebox";
|
|
5
|
+
import { toolText } from "../../agent/tool-result.mjs";
|
|
6
|
+
import { callBrowserDaemon } from "../client/rpc.mjs";
|
|
7
|
+
|
|
8
|
+
export function createBrowserTools({ stateRoot = join(homedir(), ".march") } = {}) {
|
|
9
|
+
return [
|
|
10
|
+
browserTabsTool(stateRoot),
|
|
11
|
+
browserOpenTool(stateRoot),
|
|
12
|
+
browserReadTool(stateRoot),
|
|
13
|
+
browserScriptTool(stateRoot),
|
|
14
|
+
];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function browserTabsTool(stateRoot) {
|
|
18
|
+
return defineTool({
|
|
19
|
+
name: "browser_tabs",
|
|
20
|
+
label: "Browser Tabs",
|
|
21
|
+
description: "List all tabs visible to the March browser extension. Use this first to choose a tabId.",
|
|
22
|
+
parameters: Type.Object({}),
|
|
23
|
+
execute: async () => safeToolJson(() => callBrowserDaemon({ stateRoot, method: "tabs" })),
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function browserOpenTool(stateRoot) {
|
|
28
|
+
return defineTool({
|
|
29
|
+
name: "browser_open",
|
|
30
|
+
label: "Browser Open",
|
|
31
|
+
description: "Create, navigate, focus, close, reload, back, or forward browser tabs. Returns the affected tab when available.",
|
|
32
|
+
parameters: Type.Object({
|
|
33
|
+
action: Type.String({ enum: ["new", "navigate", "focus", "close", "reload", "back", "forward"] }),
|
|
34
|
+
url: Type.Optional(Type.String({ description: "URL for new or navigate actions." })),
|
|
35
|
+
tabId: Type.Optional(Type.String({ description: "Target tab id for existing-tab actions." })),
|
|
36
|
+
active: Type.Optional(Type.Boolean({ description: "Whether a new tab should become active. Default true." })),
|
|
37
|
+
}),
|
|
38
|
+
execute: async (_id, params) => safeToolJson(() => callBrowserDaemon({ stateRoot, method: "open", params })),
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function browserReadTool(stateRoot) {
|
|
43
|
+
return defineTool({
|
|
44
|
+
name: "browser_read",
|
|
45
|
+
label: "Browser Read",
|
|
46
|
+
description: "Read content from a specific browser tab. Defaults to visible text and interactive elements; can include HTML.",
|
|
47
|
+
parameters: Type.Object({
|
|
48
|
+
tabId: Type.String({ description: "Target tab id from browser_tabs or browser_open." }),
|
|
49
|
+
include: Type.Optional(Type.Object({
|
|
50
|
+
text: Type.Optional(Type.Boolean()),
|
|
51
|
+
html: Type.Optional(Type.Boolean()),
|
|
52
|
+
elements: Type.Optional(Type.Boolean()),
|
|
53
|
+
})),
|
|
54
|
+
}),
|
|
55
|
+
execute: async (_id, params) => safeToolJson(() => callBrowserDaemon({ stateRoot, method: "read", params })),
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function browserScriptTool(stateRoot) {
|
|
60
|
+
return defineTool({
|
|
61
|
+
name: "browser_script",
|
|
62
|
+
label: "Browser Script",
|
|
63
|
+
description: "Execute arbitrary JavaScript in a specific browser tab. The code may return a JSON-serializable value or a Promise.",
|
|
64
|
+
parameters: Type.Object({
|
|
65
|
+
tabId: Type.String({ description: "Target tab id from browser_tabs or browser_open." }),
|
|
66
|
+
code: Type.String({ description: "JavaScript function body. Use return to send a result back." }),
|
|
67
|
+
awaitPromise: Type.Optional(Type.Boolean({ description: "Await a returned Promise. Default true." })),
|
|
68
|
+
timeoutMs: Type.Optional(Type.Number({ description: "Request timeout in milliseconds. Default 30000." })),
|
|
69
|
+
}),
|
|
70
|
+
execute: async (_id, params) => safeToolJson(() => callBrowserDaemon({
|
|
71
|
+
stateRoot,
|
|
72
|
+
method: "script",
|
|
73
|
+
params,
|
|
74
|
+
timeoutMs: params.timeoutMs ?? 30000,
|
|
75
|
+
})),
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function safeToolJson(run) {
|
|
80
|
+
try {
|
|
81
|
+
return toolJson(await run());
|
|
82
|
+
} catch (err) {
|
|
83
|
+
return toolJson({ ok: false, error: err.message }, { error: true });
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function toolJson(payload, details = {}) {
|
|
88
|
+
return toolText(JSON.stringify(payload, null, 2), details);
|
|
89
|
+
}
|
package/src/cli/args.mjs
CHANGED
|
@@ -28,7 +28,7 @@ export function parseCliArgs(argv) {
|
|
|
28
28
|
allowPositionals: true,
|
|
29
29
|
});
|
|
30
30
|
|
|
31
|
-
const commandName = ["login", "provider", "websearch", "memory"].includes(positionals[0]) ? positionals[0] : null;
|
|
31
|
+
const commandName = ["login", "provider", "websearch", "memory", "browser"].includes(positionals[0]) ? positionals[0] : null;
|
|
32
32
|
|
|
33
33
|
return {
|
|
34
34
|
command: commandName ? { name: commandName, args: positionals.slice(1) } : null,
|
|
@@ -70,6 +70,9 @@ Usage:
|
|
|
70
70
|
march memory add <url>
|
|
71
71
|
march memory list
|
|
72
72
|
march memory remove <name>
|
|
73
|
+
march browser install Install the developer browser extension
|
|
74
|
+
march browser status Show browser daemon/extension status
|
|
75
|
+
march browser restart Restart the browser daemon
|
|
73
76
|
|
|
74
77
|
Options:
|
|
75
78
|
-m, --model <id> Initial model ID override
|
package/src/main.mjs
CHANGED
|
@@ -37,6 +37,9 @@ import { registerSuperGrokOAuthProvider } from "./supergrok/oauth-provider.mjs";
|
|
|
37
37
|
import { installNetworkEnvironment } from "./network/environment.mjs";
|
|
38
38
|
import { runMemoryCommand } from "./memory/command.mjs";
|
|
39
39
|
import { normalizeRemoteMemorySources } from "./memory/remote/config.mjs";
|
|
40
|
+
import { resolveMemoryRoot } from "./memory/root.mjs";
|
|
41
|
+
import { runBrowserCommand } from "./browser/cli/command.mjs";
|
|
42
|
+
import { ensureBrowserDaemon } from "./browser/client/lifecycle.mjs";
|
|
40
43
|
export async function run(argv) {
|
|
41
44
|
const cwd = process.cwd();
|
|
42
45
|
loadDotEnv(cwd);
|
|
@@ -49,6 +52,7 @@ export async function run(argv) {
|
|
|
49
52
|
}
|
|
50
53
|
|
|
51
54
|
const config = loadConfig(cwd);
|
|
55
|
+
const stateRoot = join(homedir(), ".march");
|
|
52
56
|
const useRuntimeProcess = process.env.MARCH_RUNTIME_PROCESS !== "0";
|
|
53
57
|
installNetworkEnvironment(config.network);
|
|
54
58
|
if (args.command?.name === "login") {
|
|
@@ -70,11 +74,19 @@ export async function run(argv) {
|
|
|
70
74
|
return 1;
|
|
71
75
|
}
|
|
72
76
|
if (args.command?.name === "memory") {
|
|
73
|
-
args.memoryRoot = resolveMemoryRoot(config.memoryRoot,
|
|
77
|
+
args.memoryRoot = resolveMemoryRoot(config.memoryRoot, stateRoot);
|
|
74
78
|
return await runMemoryCommand(args, { homeDir: homedir() });
|
|
75
79
|
}
|
|
76
|
-
|
|
80
|
+
if (args.command?.name === "browser") {
|
|
81
|
+
try {
|
|
82
|
+
return await runBrowserCommand(args, { stateRoot });
|
|
83
|
+
} catch (err) {
|
|
84
|
+
process.stderr.write(`Error: ${err.message}\n`);
|
|
85
|
+
return 1;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
77
88
|
if (!existsSync(stateRoot)) mkdirSync(stateRoot, { recursive: true });
|
|
89
|
+
await ensureBrowserDaemon({ stateRoot }).catch(() => {});
|
|
78
90
|
const logger = createLogger({ logDir: join(stateRoot, "logs") });
|
|
79
91
|
installProcessLogHandlers(logger);
|
|
80
92
|
logger.event("process.start", {
|
|
@@ -223,7 +235,6 @@ export async function run(argv) {
|
|
|
223
235
|
modeState,
|
|
224
236
|
});
|
|
225
237
|
|
|
226
|
-
|
|
227
238
|
const startupResume = await resumeStartupSession({
|
|
228
239
|
resumeId: args.resume,
|
|
229
240
|
runner,
|
|
@@ -233,7 +244,6 @@ export async function run(argv) {
|
|
|
233
244
|
});
|
|
234
245
|
refreshStatusBar();
|
|
235
246
|
|
|
236
|
-
|
|
237
247
|
if (args.prompt) {
|
|
238
248
|
turnRunning = true;
|
|
239
249
|
try {
|
|
@@ -285,10 +295,4 @@ export async function run(argv) {
|
|
|
285
295
|
return 0;
|
|
286
296
|
}
|
|
287
297
|
|
|
288
|
-
function resolveMemoryRoot(configured, stateRoot) {
|
|
289
|
-
if (configured) return resolve(String(configured));
|
|
290
|
-
if (process.env.MARCH_MEMORY_ROOT) return resolve(process.env.MARCH_MEMORY_ROOT);
|
|
291
|
-
return resolve(stateRoot, "March Memories");
|
|
292
|
-
}
|
|
293
|
-
|
|
294
298
|
if (process.argv[1] === fileURLToPath(import.meta.url)) process.exitCode = await run(process.argv.slice(2));
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
|
|
3
|
+
export function resolveMemoryRoot(configured, stateRoot) {
|
|
4
|
+
if (configured) return resolve(String(configured));
|
|
5
|
+
if (process.env.MARCH_MEMORY_ROOT) return resolve(process.env.MARCH_MEMORY_ROOT);
|
|
6
|
+
return resolve(stateRoot, "March Memories");
|
|
7
|
+
}
|