effing-use 0.1.0 → 0.2.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.
@@ -13,13 +13,21 @@ interface SessionEntry {
13
13
  page: Page;
14
14
  consoleLogs: Array<{ type: string; text: string; at: string }>;
15
15
  networkLogs: Array<{ method: string; url: string; status: number }>;
16
+ mustObserve: boolean;
16
17
  }
17
18
 
18
19
  const sessions = new Map<string, SessionEntry>();
19
20
 
20
21
  async function getBrowser(config: Config): Promise<Browser> {
22
+ if (browser && !browser.isConnected()) {
23
+ sessions.clear();
24
+ browser = null;
25
+ }
21
26
  if (!browser) {
22
- browser = await chromium.launch({ headless: config.headless });
27
+ browser = await chromium.launch({
28
+ headless: config.headless,
29
+ args: ["--no-sandbox", "--disable-dev-shm-usage"],
30
+ });
23
31
  }
24
32
  return browser;
25
33
  }
@@ -29,7 +37,8 @@ export async function getPage(
29
37
  sessionId = "default",
30
38
  ): Promise<Page> {
31
39
  const existing = sessions.get(sessionId);
32
- if (existing) return existing.page;
40
+ if (existing && !existing.page.isClosed()) return existing.page;
41
+ if (existing) sessions.delete(sessionId);
33
42
  const b = await getBrowser(config);
34
43
  const context = await b.newContext({
35
44
  viewport: { width: config.viewportW, height: config.viewportH },
@@ -40,6 +49,7 @@ export async function getPage(
40
49
  page,
41
50
  consoleLogs: [],
42
51
  networkLogs: [],
52
+ mustObserve: false,
43
53
  };
44
54
  page.on("console", (msg) => {
45
55
  entry.consoleLogs.push({
@@ -66,7 +76,7 @@ export async function getContext(
66
76
  sessionId = "default",
67
77
  ): Promise<BrowserContext> {
68
78
  const existing = sessions.get(sessionId);
69
- if (existing) return existing.context;
79
+ if (existing && !existing.page.isClosed()) return existing.context;
70
80
  await getPage(config, sessionId);
71
81
  return sessions.get(sessionId)!.context;
72
82
  }
@@ -83,10 +93,24 @@ export function getNetworkLogs(
83
93
  return sessions.get(sessionId)?.networkLogs ?? [];
84
94
  }
85
95
 
96
+ export function getMustObserve(sessionId = "default"): boolean {
97
+ return sessions.get(sessionId)?.mustObserve ?? false;
98
+ }
99
+
100
+ export function setMustObserve(sessionId = "default", v: boolean): void {
101
+ const s = sessions.get(sessionId);
102
+ if (s) s.mustObserve = v;
103
+ }
104
+
105
+ export function clearMustObserve(sessionId = "default"): void {
106
+ const s = sessions.get(sessionId);
107
+ if (s) s.mustObserve = false;
108
+ }
109
+
86
110
  export async function closeSession(sessionId = "default"): Promise<void> {
87
111
  const s = sessions.get(sessionId);
88
112
  if (s) {
89
- await s.context.close();
113
+ await s.context.close().catch(() => undefined);
90
114
  sessions.delete(sessionId);
91
115
  }
92
116
  }
@@ -0,0 +1,100 @@
1
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import type { Config } from "../config.js";
4
+
5
+ export type StateData = {
6
+ notes: string[];
7
+ lastActions: string[];
8
+ };
9
+
10
+ const stateCache = new Map<string, StateData>();
11
+
12
+ function sanitizeSegment(s: string): string {
13
+ return s.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 64) || "default";
14
+ }
15
+
16
+ function statePath(config: Config, sessionId: string): string {
17
+ return join(config.outputDir, "state", `${sanitizeSegment(sessionId)}.md`);
18
+ }
19
+
20
+ function getState(sessionId: string): StateData {
21
+ let s = stateCache.get(sessionId);
22
+ if (!s) {
23
+ s = { notes: [], lastActions: [] };
24
+ stateCache.set(sessionId, s);
25
+ }
26
+ return s;
27
+ }
28
+
29
+ export async function appendNote(
30
+ config: Config,
31
+ sessionId: string,
32
+ note: string,
33
+ ): Promise<void> {
34
+ const s = getState(sessionId);
35
+ const line = note.slice(0, 300);
36
+ s.notes.push(line);
37
+ const max = config.stateMaxLines ?? 40;
38
+ while (s.notes.length > max) s.notes.shift();
39
+ await persist(config, sessionId);
40
+ }
41
+
42
+ export async function appendAction(
43
+ config: Config,
44
+ sessionId: string,
45
+ entry: string,
46
+ ): Promise<void> {
47
+ const s = getState(sessionId);
48
+ s.lastActions.push(entry.slice(0, 200));
49
+ while (s.lastActions.length > 10) s.lastActions.shift();
50
+ await persist(config, sessionId);
51
+ }
52
+
53
+ async function persist(config: Config, sessionId: string): Promise<void> {
54
+ const s = getState(sessionId);
55
+ const path = statePath(config, sessionId);
56
+ await mkdir(join(config.outputDir, "state"), { recursive: true });
57
+ const content = [
58
+ `# state:${sessionId}`,
59
+ `## notes`,
60
+ ...s.notes.map((n) => `- ${n}`),
61
+ `## lastActions`,
62
+ ...s.lastActions.map((a) => `- ${a}`),
63
+ ].join("\n");
64
+ await writeFile(path, content, "utf-8").catch(() => undefined);
65
+ }
66
+
67
+ export async function readState(
68
+ config: Config,
69
+ sessionId: string,
70
+ ): Promise<{ notes: string; lastActions: string[]; path: string }> {
71
+ const s = getState(sessionId);
72
+ // Try to load from disk if cache empty
73
+ if (s.notes.length === 0 && s.lastActions.length === 0) {
74
+ try {
75
+ const raw = await readFile(statePath(config, sessionId), "utf-8");
76
+ const lines = raw.split("\n");
77
+ let section: "notes" | "actions" | null = null;
78
+ for (const l of lines) {
79
+ if (l.startsWith("## notes")) section = "notes";
80
+ else if (l.startsWith("## lastActions")) section = "actions";
81
+ else if (l.startsWith("- ") && section === "notes")
82
+ s.notes.push(l.slice(2));
83
+ else if (l.startsWith("- ") && section === "actions")
84
+ s.lastActions.push(l.slice(2));
85
+ }
86
+ } catch {
87
+ /* no file yet */
88
+ }
89
+ }
90
+ const notes = s.notes.join("\n").slice(0, 1500);
91
+ return {
92
+ notes,
93
+ lastActions: [...s.lastActions],
94
+ path: statePath(config, sessionId),
95
+ };
96
+ }
97
+
98
+ export function clearState(sessionId: string): void {
99
+ stateCache.delete(sessionId);
100
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,173 @@
1
+ #!/usr/bin/env bun
2
+ // CLI face — same engine via HTTP transport (model-agnostic).
3
+ // Usage: effing-use observe --kind snapshot --mode delta --session dev
4
+ // effing-use act --action click --target e5 --expect 'url~/dashboard'
5
+ // effing-use extract --kind state
6
+
7
+ const HELP = `effing-use CLI — same engine as MCP, over HTTP
8
+ Usage:
9
+ effing-use observe --kind <snapshot|screenshot|url|title|console|network|tabs|focused> [--mode full|delta] [--scope <css>] [--session <id>]
10
+ effing-use act --action <action> [--target <ref>] [--value <val>] [--expect <expr>] [--approve] [--session <id>]
11
+ effing-use act --action batch --file steps.json
12
+ effing-use extract --kind <text|html|table|query|pdf|trace_start|trace_stop|state> [--selector <css>] [--session <id>]
13
+ effing-use --help
14
+
15
+ Env: EFFING_USE_URL (default http://localhost:3123/mcp)
16
+ `;
17
+
18
+ function parseArgs(argv: string[]): Record<string, string | boolean> {
19
+ const out: Record<string, string | boolean> = {};
20
+ for (let i = 0; i < argv.length; i++) {
21
+ const a = argv[i];
22
+ if (a === "--help" || a === "-h") out.help = true;
23
+ else if (a.startsWith("--")) {
24
+ const k = a.slice(2);
25
+ const v = argv[i + 1];
26
+ if (v && !v.startsWith("--")) {
27
+ out[k] = v;
28
+ i++;
29
+ } else out[k] = true;
30
+ }
31
+ }
32
+ return out;
33
+ }
34
+
35
+ async function main() {
36
+ const [, , verb, ...rest] = process.argv;
37
+ const args = parseArgs(rest);
38
+
39
+ if (!verb || args.help || verb === "--help" || verb === "-h") {
40
+ process.stdout.write(HELP);
41
+ process.exit(0);
42
+ }
43
+
44
+ const url = process.env.EFFING_USE_URL ?? "http://localhost:3123/mcp";
45
+ // For CLI we call the HTTP MCP endpoint via JSON-RPC
46
+ // Minimal client: POST tools/call
47
+ const toolMap: Record<string, string> = {
48
+ observe: "browser_observe",
49
+ act: "browser_act",
50
+ extract: "browser_extract",
51
+ };
52
+ const tool = toolMap[verb];
53
+ if (!tool) {
54
+ process.stderr.write(`Unknown verb "${verb}". Use observe|act|extract.\n`);
55
+ process.exit(1);
56
+ }
57
+
58
+ // Build arguments object for the tool
59
+ const toolArgs: Record<string, unknown> = {};
60
+ for (const [k, v] of Object.entries(args)) {
61
+ if (k === "help") continue;
62
+ // coerce
63
+ if (k === "approve") toolArgs[k] = v === true || v === "true";
64
+ else if (k === "limit") toolArgs[k] = Number(v);
65
+ else toolArgs[k] = v;
66
+ }
67
+ // Map CLI names to tool schema names
68
+ if (
69
+ toolArgs.kind === undefined &&
70
+ verb === "act" &&
71
+ toolArgs.action === undefined
72
+ ) {
73
+ // allow --kind alias? no
74
+ }
75
+ // handle --file for batch
76
+ if (toolArgs.file) {
77
+ try {
78
+ const raw = await Bun.file(String(toolArgs.file)).text();
79
+ const steps = JSON.parse(raw);
80
+ toolArgs.steps = Array.isArray(steps) ? steps : (steps.steps ?? []);
81
+ } catch (e) {
82
+ process.stderr.write(
83
+ `Failed to read --file: ${e instanceof Error ? e.message : String(e)}\n`,
84
+ );
85
+ process.exit(1);
86
+ }
87
+ delete toolArgs.file;
88
+ }
89
+ // sessionId alias
90
+ if (toolArgs.session && !toolArgs.sessionId) {
91
+ toolArgs.sessionId = toolArgs.session;
92
+ delete toolArgs.session;
93
+ }
94
+
95
+ const body = JSON.stringify({
96
+ jsonrpc: "2.0",
97
+ id: 1,
98
+ method: "tools/call",
99
+ params: { name: tool, arguments: toolArgs },
100
+ });
101
+
102
+ let res: Response;
103
+ try {
104
+ res = await fetch(url, {
105
+ method: "POST",
106
+ headers: {
107
+ "Content-Type": "application/json",
108
+ Accept: "application/json, text/event-stream",
109
+ },
110
+ body,
111
+ });
112
+ } catch {
113
+ process.stderr.write(
114
+ JSON.stringify({
115
+ ok: false,
116
+ code: "E_NO_SERVER",
117
+ message: `Cannot reach ${url}`,
118
+ hint: "start: bun src/http.ts",
119
+ }) + "\n",
120
+ );
121
+ process.exit(2);
122
+ }
123
+
124
+ if (!res.ok) {
125
+ // Try to parse SSE or JSON
126
+ const text = await res.text().catch(() => "");
127
+ // SSE format: data: {...}
128
+ const m = /data:\s*(\{.*\})/.exec(text);
129
+ const payload = m ? m[1] : text;
130
+ try {
131
+ const j = JSON.parse(payload);
132
+ const content =
133
+ j.result?.content?.[0]?.text ?? j.error?.message ?? payload;
134
+ // content is often JSON string of the tool result
135
+ try {
136
+ const inner = JSON.parse(content);
137
+ process.stdout.write(JSON.stringify(inner) + "\n");
138
+ process.exit(inner.ok === false ? 1 : 0);
139
+ } catch {
140
+ process.stdout.write(content + "\n");
141
+ process.exit(0);
142
+ }
143
+ } catch {
144
+ process.stderr.write(text + "\n");
145
+ process.exit(1);
146
+ }
147
+ }
148
+
149
+ const text = await res.text();
150
+ // Handle SSE stream
151
+ const lines = text.split("\n").filter((l) => l.startsWith("data:"));
152
+ const last = lines[lines.length - 1]?.slice(5).trim() ?? text;
153
+ try {
154
+ const j = JSON.parse(last);
155
+ const content = j.result?.content?.[0]?.text ?? last;
156
+ try {
157
+ const inner = JSON.parse(content);
158
+ process.stdout.write(JSON.stringify(inner) + "\n");
159
+ process.exit(inner.ok === false ? 1 : 0);
160
+ } catch {
161
+ process.stdout.write(content + "\n");
162
+ process.exit(0);
163
+ }
164
+ } catch {
165
+ process.stdout.write(text + "\n");
166
+ process.exit(0);
167
+ }
168
+ }
169
+
170
+ main().catch((e) => {
171
+ process.stderr.write(String(e) + "\n");
172
+ process.exit(1);
173
+ });
package/src/config.ts CHANGED
@@ -1,5 +1,12 @@
1
1
  import * as v from "valibot";
2
2
 
3
+ // The npm package version — single source of truth is package.json, so the MCP
4
+ // server's reported version (serverInfo.version) never drifts from the
5
+ // published release.
6
+ export const VERSION = (
7
+ await Bun.file(new URL("../package.json", import.meta.url)).json()
8
+ ).version as string;
9
+
3
10
  const ConfigSchema = v.object({
4
11
  headless: v.optional(v.boolean(), true),
5
12
  viewportW: v.optional(v.number(), 1280),
@@ -8,6 +15,11 @@ const ConfigSchema = v.object({
8
15
  outputDir: v.optional(v.string(), ".browser-use"),
9
16
  outputMaxChars: v.optional(v.number(), 4000),
10
17
  allowEval: v.optional(v.boolean(), false),
18
+ deltaDefault: v.optional(v.boolean(), true),
19
+ effectMaxChars: v.optional(v.number(), 800),
20
+ stateMaxLines: v.optional(v.number(), 40),
21
+ recordRedact: v.optional(v.boolean(), true),
22
+ effingUseUrl: v.optional(v.string(), "http://localhost:3123/mcp"),
11
23
  });
12
24
 
13
25
  export type Config = v.InferOutput<typeof ConfigSchema>;
@@ -21,5 +33,10 @@ export function loadConfig(): Config {
21
33
  outputDir: process.env.OUTPUT_DIR ?? ".browser-use",
22
34
  outputMaxChars: Number(process.env.OUTPUT_MAX_CHARS ?? 4000),
23
35
  allowEval: process.env.ALLOW_EVAL === "true",
36
+ deltaDefault: process.env.DELTA_DEFAULT !== "false",
37
+ effectMaxChars: Number(process.env.EFFECT_MAX_CHARS ?? 800),
38
+ stateMaxLines: Number(process.env.STATE_MAX_LINES ?? 40),
39
+ recordRedact: process.env.RECORD_REDACT !== "false",
40
+ effingUseUrl: process.env.EFFING_USE_URL ?? "http://localhost:3123/mcp",
24
41
  });
25
42
  }
package/src/http.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  import { HttpTransport } from "@tmcp/transport-http";
3
3
  import { server } from "./server.js";
4
4
 
5
- const port = Number(process.env.PORT ?? 3000);
5
+ const port = Number(process.env.PORT ?? 3123);
6
6
 
7
7
  // Streamable HTTP transport (MCP spec). Serves the MCP endpoint at /mcp.
8
8
  // No auth on local loopback; put a reverse proxy / tunnel in front for remote use.
@@ -11,6 +11,15 @@ const transport = new HttpTransport(server, { path: "/mcp" });
11
11
 
12
12
  Bun.serve({
13
13
  port,
14
+ hostname: "127.0.0.1",
15
+ // Bun closes idle connections after 10s by default (idleTimeout), and the
16
+ // timer applies even while a response is being streamed. The MCP
17
+ // Streamable-HTTP SSE notification stream sits idle between server->client
18
+ // messages, so Bun was killing it every ~10s and clients logged
19
+ // "Error reading from async stream: terminated" on a loop until the
20
+ // connection hard-failed. idleTimeout: 0 disables the idle close entirely
21
+ // (safe: this server is meant for local loopback / private networks).
22
+ idleTimeout: 0,
14
23
  async fetch(req) {
15
24
  const url = new URL(req.url);
16
25
  if (url.pathname === "/healthz") {
package/src/server.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { McpServer } from "tmcp";
2
2
  import { ValibotJsonSchemaAdapter } from "@tmcp/adapter-valibot";
3
+ import { VERSION } from "./config.js";
3
4
  import { actTool } from "./tools/act.js";
4
5
  import { observeTool } from "./tools/observe.js";
5
6
  import { extractTool } from "./tools/extract.js";
@@ -9,7 +10,7 @@ const adapter = new ValibotJsonSchemaAdapter();
9
10
  export const server = new McpServer(
10
11
  {
11
12
  name: "effing-use",
12
- version: "0.1.0",
13
+ version: VERSION,
13
14
  description:
14
15
  "Token-efficient browser control: 3 tools (act, observe, extract).",
15
16
  },
package/src/tools/act.ts CHANGED
@@ -34,6 +34,11 @@ export const ACT_ACTIONS = [
34
34
  "tab_select",
35
35
  "tab_close",
36
36
  "resize",
37
+ "note",
38
+ "record_start",
39
+ "record_stop",
40
+ "compile",
41
+ "replay",
37
42
  ] as const;
38
43
 
39
44
  const StepSchema = v.object({
@@ -53,6 +58,8 @@ export const actTool = defineTool(
53
58
  value: v.optional(v.string()),
54
59
  sessionId: v.optional(v.string()),
55
60
  steps: v.optional(v.array(StepSchema)),
61
+ expect: v.optional(v.string()),
62
+ approve: v.optional(v.boolean()),
56
63
  }),
57
64
  annotations: {
58
65
  readOnlyHint: false,
@@ -61,13 +68,13 @@ export const actTool = defineTool(
61
68
  openWorldHint: true,
62
69
  },
63
70
  },
64
- async ({ action, target, value, sessionId, steps }) => {
71
+ async ({ action, target, value, sessionId, steps, expect, approve }) => {
65
72
  const config = loadConfig();
66
73
  const sid = sessionId ?? "default";
67
74
  try {
68
75
  const page = await getPage(config, sid);
69
76
  const context = await getContext(config, sid);
70
- const opts = { context, sessionId: sid };
77
+ const opts = { context, sessionId: sid, expect, approve };
71
78
  if (action === "batch") {
72
79
  if (!steps || steps.length === 0)
73
80
  return tool.text(
@@ -20,6 +20,7 @@ export const extractTool = defineTool(
20
20
  "pdf",
21
21
  "trace_start",
22
22
  "trace_stop",
23
+ "state",
23
24
  ]),
24
25
  selector: v.optional(v.string()),
25
26
  mode: v.optional(v.string()),
@@ -26,6 +26,8 @@ export const observeTool = defineTool(
26
26
  limit: v.optional(v.number()),
27
27
  sessionId: v.optional(v.string()),
28
28
  inline: v.optional(v.boolean()),
29
+ mode: v.optional(v.picklist(["full", "delta"])),
30
+ scope: v.optional(v.string()),
29
31
  }),
30
32
  annotations: {
31
33
  readOnlyHint: true,
@@ -34,7 +36,7 @@ export const observeTool = defineTool(
34
36
  openWorldHint: false,
35
37
  },
36
38
  },
37
- async ({ kind, target, limit, sessionId }) => {
39
+ async ({ kind, target, limit, sessionId, mode, scope }) => {
38
40
  const config = loadConfig();
39
41
  const sid = sessionId ?? "default";
40
42
  try {
@@ -43,6 +45,8 @@ export const observeTool = defineTool(
43
45
  const result = await doObserve(page, config, kind, target, limit, {
44
46
  context,
45
47
  sessionId: sid,
48
+ mode: mode as "full" | "delta" | undefined,
49
+ scope,
46
50
  });
47
51
  return tool.text(JSON.stringify({ ok: true, kind, ...result }));
48
52
  } catch (e) {