march-cli 0.1.27 → 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/remote-ui-client.mjs +1 -1
- package/src/agent/runtime/runner-runtime-host.mjs +1 -0
- package/src/agent/runtime/ui-event-bridge.mjs +2 -2
- package/src/agent/session/session-options.mjs +2 -1
- package/src/agent/tools.mjs +3 -1
- package/src/agent/turn/turn-runner.mjs +2 -2
- 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/cli/fallback-ui.mjs +4 -4
- package/src/cli/repl-loop.mjs +5 -5
- package/src/cli/tui/layout/main-pane-layout.mjs +2 -1
- package/src/cli/tui/output/tool-card-renderer.mjs +8 -2
- package/src/cli/tui/recall-rendering.mjs +6 -7
- package/src/cli/tui/status/status-bar.mjs +11 -3
- package/src/cli/tui/tui-input-controller.mjs +2 -1
- package/src/cli/ui.mjs +9 -10
- package/src/context/system-core/base.md +4 -3
- package/src/main.mjs +14 -10
- package/src/memory/markdown/markdown-recall.mjs +1 -1
- package/src/memory/markdown-store.mjs +1 -1
- package/src/memory/markdown-tools.mjs +5 -5
- 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,
|
|
@@ -13,7 +13,7 @@ export function createRemoteRuntimeUiClient(peer) {
|
|
|
13
13
|
retryEnd: (event) => peer.notify("uiEvent", { type: "retry_end", ...event }),
|
|
14
14
|
status: (text) => peer.notify("uiEvent", { type: "status", text }),
|
|
15
15
|
debugLines: (lines) => peer.notify("uiEvent", { type: "debug_lines", lines }),
|
|
16
|
-
|
|
16
|
+
recall: ({ source, hints }) => peer.notify("uiEvent", { type: "recall", source, hints }),
|
|
17
17
|
editDiff: (path, diffLines) => peer.notify("uiEvent", { type: "edit_diff", path, diffLines }),
|
|
18
18
|
requestPermission: (request) => peer.call("uiRequest", { type: "permission_request", ...request }),
|
|
19
19
|
};
|
|
@@ -50,7 +50,7 @@ export function createRuntimeUiClient(eventBus) {
|
|
|
50
50
|
retryEnd: (event) => eventBus.emit({ type: "retry_end", ...event }),
|
|
51
51
|
status: (text) => eventBus.emit({ type: "status", text }),
|
|
52
52
|
debugLines: (lines) => eventBus.emit({ type: "debug_lines", lines }),
|
|
53
|
-
|
|
53
|
+
recall: ({ source, hints }) => eventBus.emit({ type: "recall", source, hints }),
|
|
54
54
|
editDiff: (path, diffLines) => eventBus.emit({ type: "edit_diff", path, diffLines }),
|
|
55
55
|
requestPermission: (request) => eventBus.request({ type: "permission_request", ...request }),
|
|
56
56
|
};
|
|
@@ -71,7 +71,7 @@ export function dispatchRuntimeUiEvent(ui, event) {
|
|
|
71
71
|
case "retry_end": return ui.retryEnd?.(pickRetryEnd(event));
|
|
72
72
|
case "status": return ui.status?.(event.text);
|
|
73
73
|
case "debug_lines": return writeDebugLines(ui, event.lines);
|
|
74
|
-
case "
|
|
74
|
+
case "recall": return ui.recall?.({ source: event.source, hints: event.hints });
|
|
75
75
|
case "edit_diff": return ui.editDiff?.(event.path, event.diffLines);
|
|
76
76
|
case "permission_request": return ui.requestPermission?.({ toolName: event.toolName, params: event.params, category: event.category });
|
|
77
77
|
default: return undefined;
|
|
@@ -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
|
];
|
|
@@ -42,7 +42,7 @@ export async function runRunnerTurn({
|
|
|
42
42
|
if (hints.length > 0) {
|
|
43
43
|
midTurnRecallHints.push(...hints);
|
|
44
44
|
queueMidTurnRecallHints(activeSession, hints, logger);
|
|
45
|
-
ui.
|
|
45
|
+
ui.recall?.({ source: "assistant", hints });
|
|
46
46
|
}
|
|
47
47
|
}
|
|
48
48
|
});
|
|
@@ -93,7 +93,7 @@ function queueMidTurnRecallHints(session, hints, logger) {
|
|
|
93
93
|
const content = formatRecallHints("assistant", hints);
|
|
94
94
|
if (!content) return;
|
|
95
95
|
const injected = session.sendCustomMessage?.({
|
|
96
|
-
customType: "march.
|
|
96
|
+
customType: "march.recall",
|
|
97
97
|
content,
|
|
98
98
|
display: false,
|
|
99
99
|
details: { source: "assistant" },
|
|
@@ -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
|
+
}
|