jeo-code 0.7.31 → 0.7.33

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.
Files changed (39) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.ja.md +4 -2
  3. package/README.ko.md +4 -2
  4. package/README.md +4 -2
  5. package/README.zh.md +4 -2
  6. package/package.json +4 -4
  7. package/scripts/install.sh +19 -0
  8. package/scripts/uninstall.sh +2 -2
  9. package/src/agent/AGENTS.md +15 -0
  10. package/src/agent/ast-edit-tool.ts +123 -0
  11. package/src/agent/ast-file-scan.ts +62 -0
  12. package/src/agent/ast-grep-tool.ts +107 -0
  13. package/src/agent/ast-match.ts +241 -0
  14. package/src/agent/browser-session.ts +104 -0
  15. package/src/agent/browser-tab.ts +145 -0
  16. package/src/agent/browser-tool.ts +188 -0
  17. package/src/agent/debug-session.ts +435 -0
  18. package/src/agent/debug-tool.ts +89 -0
  19. package/src/agent/engine.ts +72 -12
  20. package/src/agent/goal-tool.ts +57 -0
  21. package/src/agent/irc-tool.ts +71 -0
  22. package/src/agent/job-registry.ts +163 -0
  23. package/src/agent/job-tool.ts +106 -0
  24. package/src/agent/lsp-rename-tool.ts +108 -0
  25. package/src/agent/lsp-tool.ts +177 -0
  26. package/src/agent/session.ts +27 -0
  27. package/src/agent/subagent-registry.ts +40 -4
  28. package/src/agent/subagent-tool.ts +27 -7
  29. package/src/agent/subagents.ts +1 -1
  30. package/src/agent/task-tool.ts +27 -5
  31. package/src/agent/tools.ts +68 -1
  32. package/src/agent/ts-language-service.ts +173 -0
  33. package/src/commands/launch/agents-slash.ts +489 -0
  34. package/src/commands/launch/flags.ts +27 -9
  35. package/src/commands/launch/model-slash.ts +290 -0
  36. package/src/commands/launch/session-slash.ts +305 -0
  37. package/src/commands/launch/slash-handlers.ts +6 -0
  38. package/src/commands/launch/system-slash.ts +81 -0
  39. package/src/commands/launch.ts +208 -749
@@ -0,0 +1,241 @@
1
+ /**
2
+ * Structural AST pattern matcher for TypeScript/JavaScript (gjc `ast_grep`/`ast_edit`
3
+ * parity, scoped to TS/JS/TSX/JSX — jeo-code has zero native dependencies, so this is
4
+ * a pure-TS reimplementation of ast-grep's metavariable matching against the
5
+ * `typescript` compiler API's AST, NOT the real ast-grep binary and NOT multi-language.
6
+ *
7
+ * Pattern syntax (matches the `ast_grep`/`ast_edit` tool contract):
8
+ * - `$NAME` — capture exactly ONE node; two occurrences of the same name in a
9
+ * pattern must match IDENTICAL source text (e.g. `assertEqual($A, $A)`).
10
+ * - `$_` — wildcard, matches exactly one node without binding a capture.
11
+ * - `$$$NAME`/`$$$` — capture/ignore ZERO OR MORE sibling nodes in a LIST position
12
+ * (call arguments, array/object elements, statement lists, class members,
13
+ * named-import specifiers, parameter lists, ...). At most one `$$$`-style
14
+ * metavariable per list is supported.
15
+ *
16
+ * Implementation notes:
17
+ * - `ts.Node.forEachChild(cbNode, cbNodeArray)` is used to enumerate a node's
18
+ * children in canonical source order, distinguishing single-child "slots" from
19
+ * list ("NodeArray") slots — this is what lets `$$$` bind a variable-length
20
+ * span instead of a single node.
21
+ * - A metavariable identifier is frequently parsed WRAPPED in a structural node
22
+ * rather than standing bare — e.g. `($$$ARGS)` is a lone `ParameterDeclaration`
23
+ * whose `.name` is the identifier, `{ $$$IMPORTS }` is a lone `ImportSpecifier`,
24
+ * `: $_` is a `TypeReferenceNode`, `{ $$$BODY }` is a `Block` containing one
25
+ * `ExpressionStatement`. `unwrapBareWrapper()` sees through these SPECIFIC,
26
+ * well-known "no extra shape" wrappers so metavariable detection is not
27
+ * accidentally defeated by the parser's own AST shape.
28
+ * - Structural comparison otherwise requires the SAME `SyntaxKind` and recurses
29
+ * slot-by-slot. A pattern is allowed to OMIT trailing optional slots the
30
+ * candidate has (e.g. no `else` branch, no return type) and to be missing a
31
+ * LEADING/MIDDLE list-shaped slot the candidate has (typically `modifiers`/
32
+ * `decorators`, e.g. matching un-exported AND exported declarations with the
33
+ * same pattern) — up to a small bounded number of skipped candidate slots.
34
+ */
35
+ import * as ts from "typescript";
36
+
37
+ export type MetaKind = "single" | "multi" | "wild" | "wildmulti";
38
+ export interface Meta { kind: MetaKind; name: string }
39
+
40
+ function unwrapBareWrapper(node: ts.Node): ts.Node | null {
41
+ if (ts.isParameter(node) && ts.isIdentifier(node.name) && !node.type && !node.initializer
42
+ && !node.questionToken && !node.dotDotDotToken && (!node.modifiers || node.modifiers.length === 0)) {
43
+ return node.name;
44
+ }
45
+ if (ts.isImportSpecifier(node) && !node.propertyName && !node.isTypeOnly) return node.name;
46
+ if (ts.isExportSpecifier(node) && !node.propertyName && !node.isTypeOnly) return node.name;
47
+ if (ts.isBindingElement(node) && !node.propertyName && !node.initializer && !node.dotDotDotToken && ts.isIdentifier(node.name)) return node.name;
48
+ if (ts.isTypeReferenceNode(node) && ts.isIdentifier(node.typeName) && !node.typeArguments) return node.typeName;
49
+ if (ts.isExpressionStatement(node)) return node.expression;
50
+ if (ts.isShorthandPropertyAssignment(node) && !node.objectAssignmentInitializer) return node.name;
51
+ return null;
52
+ }
53
+
54
+ /** Classify a (possibly wrapper-unwrapped) node as a metavariable, or `null` for
55
+ * an ordinary AST node that must be matched structurally. */
56
+ export function metaOf(node: ts.Node): Meta | null {
57
+ const inner = unwrapBareWrapper(node);
58
+ if (inner) return metaOf(inner);
59
+ if (!ts.isIdentifier(node)) return null;
60
+ const t = node.text;
61
+ if (t === "$_") return { kind: "wild", name: "_" };
62
+ if (t === "$$$") return { kind: "wildmulti", name: "_" };
63
+ const m3 = /^\$\$\$([A-Za-z_][A-Za-z0-9_]*)$/.exec(t);
64
+ if (m3) return { kind: "multi", name: m3[1]! };
65
+ const m1 = /^\$([A-Za-z_][A-Za-z0-9_]*)$/.exec(t);
66
+ if (m1) return { kind: "single", name: m1[1]! };
67
+ return null;
68
+ }
69
+
70
+ export interface Bindings {
71
+ single: Map<string, string>;
72
+ multi: Map<string, string[]>;
73
+ }
74
+
75
+ function newBindings(): Bindings {
76
+ return { single: new Map(), multi: new Map() };
77
+ }
78
+
79
+ type Slot = { kind: "node"; node: ts.Node } | { kind: "list"; nodes: readonly ts.Node[] };
80
+
81
+ function slotsOf(node: ts.Node): Slot[] {
82
+ const out: Slot[] = [];
83
+ node.forEachChild(
84
+ child => { out.push({ kind: "node", node: child }); return undefined; },
85
+ arr => { out.push({ kind: "list", nodes: arr }); return undefined; },
86
+ );
87
+ return out;
88
+ }
89
+
90
+ function leafText(node: ts.Node): string | undefined {
91
+ if (ts.isIdentifier(node) || ts.isPrivateIdentifier(node)) return node.text;
92
+ if (ts.isStringLiteralLike(node)) return node.text;
93
+ if (ts.isNumericLiteral(node) || ts.isBigIntLiteral(node)) return node.text;
94
+ return undefined;
95
+ }
96
+
97
+ /** How many candidate-only slots (e.g. `export`/decorators the pattern omitted) a
98
+ * single structural comparison may skip over before giving up. */
99
+ const MAX_SLOT_SKIPS = 2;
100
+
101
+ function matchNode(pattern: ts.Node, patternSf: ts.SourceFile, candidate: ts.Node, candidateSf: ts.SourceFile, b: Bindings): boolean {
102
+ const meta = metaOf(pattern);
103
+ if (meta) {
104
+ if (meta.kind === "wild") return true;
105
+ if (meta.kind === "single") {
106
+ const text = candidate.getText(candidateSf).trim();
107
+ const prev = b.single.get(meta.name);
108
+ if (prev !== undefined) return prev === text;
109
+ b.single.set(meta.name, text);
110
+ return true;
111
+ }
112
+ // `$$$NAME`/`$$$` reached outside a list slot (unusual pattern shape) — fall back
113
+ // to treating it as a single capture of the whole candidate rather than failing.
114
+ const text = candidate.getText(candidateSf).trim();
115
+ if (meta.kind === "multi") {
116
+ const prev = b.multi.get(meta.name);
117
+ if (prev !== undefined) return prev.join(" ") === text;
118
+ b.multi.set(meta.name, [text]);
119
+ }
120
+ return true;
121
+ }
122
+ if (pattern.kind !== candidate.kind) return false;
123
+ const lt = leafText(pattern);
124
+ if (lt !== undefined) return lt === leafText(candidate);
125
+
126
+ const pSlots = slotsOf(pattern);
127
+ const cSlots = slotsOf(candidate);
128
+ let pi = 0, ci = 0, skips = 0;
129
+ while (pi < pSlots.length) {
130
+ const ps = pSlots[pi]!;
131
+ if (ci >= cSlots.length) return false;
132
+ const cs = cSlots[ci]!;
133
+ if (ps.kind === cs.kind) {
134
+ const ok = ps.kind === "list"
135
+ ? matchList(ps.nodes, patternSf, (cs as { kind: "list"; nodes: readonly ts.Node[] }).nodes, candidateSf, b)
136
+ : matchNode((ps as { kind: "node"; node: ts.Node }).node, patternSf, (cs as { kind: "node"; node: ts.Node }).node, candidateSf, b);
137
+ if (!ok) {
138
+ if (skips < MAX_SLOT_SKIPS && ci + 1 < cSlots.length) { skips++; ci++; continue; }
139
+ return false;
140
+ }
141
+ pi++; ci++;
142
+ continue;
143
+ }
144
+ // Slot-KIND mismatch (list vs single) at this relative position — most commonly a
145
+ // `modifiers`/`decorators` list the candidate has and the pattern omitted. Skip the
146
+ // candidate slot and retry the SAME pattern slot against the next one.
147
+ if (skips < MAX_SLOT_SKIPS) { skips++; ci++; continue; }
148
+ return false;
149
+ }
150
+ return true; // trailing extra candidate slots are fine — unspecified optional parts
151
+ }
152
+
153
+ function matchList(patternNodes: readonly ts.Node[], patternSf: ts.SourceFile, candidateNodes: readonly ts.Node[], candidateSf: ts.SourceFile, b: Bindings): boolean {
154
+ const idx = patternNodes.findIndex(n => { const m = metaOf(n); return m?.kind === "multi" || m?.kind === "wildmulti"; });
155
+ if (idx === -1) {
156
+ if (patternNodes.length !== candidateNodes.length) return false;
157
+ for (let i = 0; i < patternNodes.length; i++) {
158
+ if (!matchNode(patternNodes[i]!, patternSf, candidateNodes[i]!, candidateSf, b)) return false;
159
+ }
160
+ return true;
161
+ }
162
+ const prefixLen = idx;
163
+ const suffixLen = patternNodes.length - idx - 1;
164
+ if (candidateNodes.length < prefixLen + suffixLen) return false;
165
+ for (let i = 0; i < prefixLen; i++) {
166
+ if (!matchNode(patternNodes[i]!, patternSf, candidateNodes[i]!, candidateSf, b)) return false;
167
+ }
168
+ for (let i = 0; i < suffixLen; i++) {
169
+ if (!matchNode(patternNodes[patternNodes.length - 1 - i]!, patternSf, candidateNodes[candidateNodes.length - 1 - i]!, candidateSf, b)) return false;
170
+ }
171
+ const middle = candidateNodes.slice(prefixLen, candidateNodes.length - suffixLen);
172
+ const meta = metaOf(patternNodes[idx]!)!;
173
+ if (meta.kind === "multi") {
174
+ const texts = middle.map(n => n.getText(candidateSf).trim());
175
+ const prev = b.multi.get(meta.name);
176
+ if (prev !== undefined) return prev.join(",") === texts.join(",");
177
+ b.multi.set(meta.name, texts);
178
+ }
179
+ return true;
180
+ }
181
+
182
+ /** Parse a pattern string to a single matchable node. Multi-statement snippets are
183
+ * rejected with a clear error — wrap them in a block/valid context instead (same
184
+ * discipline as real ast-grep). A bare expression statement is unwrapped to its
185
+ * expression so `$A && $A()` matches an Expression, not only an ExpressionStatement. */
186
+ export function parsePattern(src: string): { node: ts.Node; sf: ts.SourceFile } {
187
+ const sf = ts.createSourceFile("__pattern__.tsx", src, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
188
+ if (sf.statements.length !== 1) {
189
+ throw new Error(`pattern must parse to exactly one top-level statement (got ${sf.statements.length}) — wrap multi-statement snippets in a block or valid context`);
190
+ }
191
+ let node: ts.Node = sf.statements[0]!;
192
+ if (ts.isExpressionStatement(node)) node = node.expression;
193
+ return { node, sf };
194
+ }
195
+
196
+ export interface MatchCaptures {
197
+ single: Record<string, string>;
198
+ multi: Record<string, string[]>;
199
+ }
200
+
201
+ export interface MatchResult {
202
+ node: ts.Node;
203
+ captures: MatchCaptures;
204
+ }
205
+
206
+ /** Find every non-overlapping-by-construction match of `patternSrc` in `sourceFile`.
207
+ * A fast prefilter only attempts nodes whose `SyntaxKind` equals the pattern's root
208
+ * kind (unless the pattern root is itself a bare metavariable). */
209
+ export function findMatches(patternSrc: string, sourceFile: ts.SourceFile): MatchResult[] {
210
+ const { node: patternNode, sf: patternSf } = parsePattern(patternSrc);
211
+ const patternIsBareMeta = metaOf(patternNode) !== null;
212
+ const out: MatchResult[] = [];
213
+ const visit = (node: ts.Node) => {
214
+ if (patternIsBareMeta || node.kind === patternNode.kind) {
215
+ const b = newBindings();
216
+ if (matchNode(patternNode, patternSf, node, sourceFile, b)) {
217
+ out.push({ node, captures: { single: Object.fromEntries(b.single), multi: Object.fromEntries(b.multi) } });
218
+ }
219
+ }
220
+ node.forEachChild(visit);
221
+ };
222
+ sourceFile.forEachChild(visit);
223
+ return out;
224
+ }
225
+
226
+ /** Render a replacement template, substituting `$NAME`/`$$$NAME` with their bound
227
+ * capture text (multi-captures join with ", "). An unbound metavariable (including
228
+ * `$_`/`$$$`, which never bind) is left as literal text in the output. */
229
+ export function renderReplacement(template: string, captures: MatchCaptures): string {
230
+ return template.replace(/\$\$\$([A-Za-z_][A-Za-z0-9_]*)|\$([A-Za-z_][A-Za-z0-9_]*)/g, (whole, multiName?: string, singleName?: string) => {
231
+ if (multiName !== undefined) {
232
+ const vals = captures.multi[multiName];
233
+ return vals !== undefined ? vals.join(", ") : whole;
234
+ }
235
+ if (singleName !== undefined) {
236
+ const val = captures.single[singleName];
237
+ return val !== undefined ? val : whole;
238
+ }
239
+ return whole;
240
+ });
241
+ }
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Named-tab browser session registry (gjc `browser` parity, via Playwright — already
3
+ * a jeo-code dependency for its own test suite, so this reuses it rather than adding
4
+ * a second automation engine). One shared headless Chromium instance is launched
5
+ * lazily on the first `open()` and kept alive across calls; tabs are addressed by
6
+ * name and reused ("open once, reuse many times" — the real tool's stated contract).
7
+ *
8
+ * Scope, stated honestly: headless Chromium only for v1 — no existing Chrome profile
9
+ * attach, no Electron/CDP-endpoint connect, no stealth-mode patches. Those are real
10
+ * gaps versus the full tool; a plain headless session covers the large majority of
11
+ * "fetch/observe/click through a page" agent use cases.
12
+ */
13
+ import { chromium, type Browser, type Page } from "playwright";
14
+ import { createTab, type Tab } from "./browser-tab";
15
+
16
+ interface TabEntry {
17
+ page: Page;
18
+ tab: Tab;
19
+ }
20
+
21
+ export class BrowserSession {
22
+ private browser: Browser | undefined;
23
+ private tabs = new Map<string, TabEntry>();
24
+
25
+ get openTabNames(): string[] {
26
+ return [...this.tabs.keys()];
27
+ }
28
+
29
+ private async ensureBrowser(): Promise<Browser> {
30
+ if (this.browser && this.browser.isConnected()) return this.browser;
31
+ this.browser = await chromium.launch({ headless: true });
32
+ return this.browser;
33
+ }
34
+
35
+ /** Open (or reuse) a named tab. Navigates when `url` is given. */
36
+ async open(name: string, url?: string, viewport?: { width: number; height: number }): Promise<Tab> {
37
+ const browser = await this.ensureBrowser();
38
+ let entry = this.tabs.get(name);
39
+ if (!entry) {
40
+ const page = await browser.newPage(viewport ? { viewport } : undefined);
41
+ entry = { page, tab: createTab(page) };
42
+ this.tabs.set(name, entry);
43
+ } else if (viewport) {
44
+ await entry.page.setViewportSize(viewport);
45
+ }
46
+ if (url) await entry.tab.goto(url);
47
+ return entry.tab;
48
+ }
49
+
50
+ /** Look up an already-open tab by name, or throw a clear error. */
51
+ get(name: string): Tab {
52
+ const entry = this.tabs.get(name);
53
+ if (!entry) {
54
+ throw new Error(`No open tab named '${name}'. Call browser {action:"open", name} first.`);
55
+ }
56
+ return entry.tab;
57
+ }
58
+
59
+ has(name: string): boolean {
60
+ return this.tabs.has(name);
61
+ }
62
+
63
+ get browserInstance(): Browser | undefined {
64
+ return this.browser;
65
+ }
66
+
67
+ /** Close one named tab, or every tab (`all`). `all` also closes the shared browser. */
68
+ async close(name?: string, all = false): Promise<string[]> {
69
+ const closed: string[] = [];
70
+ if (all) {
71
+ for (const [n, entry] of this.tabs) {
72
+ await entry.page.close().catch(() => {});
73
+ closed.push(n);
74
+ }
75
+ this.tabs.clear();
76
+ if (this.browser) {
77
+ await this.browser.close().catch(() => {});
78
+ this.browser = undefined;
79
+ }
80
+ return closed;
81
+ }
82
+ if (name) {
83
+ const entry = this.tabs.get(name);
84
+ if (entry) {
85
+ await entry.page.close().catch(() => {});
86
+ this.tabs.delete(name);
87
+ closed.push(name);
88
+ }
89
+ }
90
+ return closed;
91
+ }
92
+
93
+ async terminate(): Promise<void> {
94
+ await this.close(undefined, true);
95
+ }
96
+ }
97
+
98
+ /** Process-wide singleton — one shared headless browser, many named tabs. */
99
+ export const browserSession = new BrowserSession();
100
+
101
+ // Safety net: never leave a headless Chromium process running past the CLI's exit.
102
+ process.on("exit", () => {
103
+ void browserSession.terminate();
104
+ });
@@ -0,0 +1,145 @@
1
+ /**
2
+ * High-level per-tab helpers for the `browser` tool (gjc `browser` parity, via
3
+ * Playwright instead of puppeteer — jeo-code already ships `playwright` for its own
4
+ * test suite; this reuses it rather than adding a second automation dependency).
5
+ *
6
+ * `observe()` mirrors gjc's accessibility-snapshot-first philosophy: it returns a
7
+ * bounded list of interactive elements (role/name/value) instead of a raw DOM dump
8
+ * or a screenshot, and tags each one with a `data-jeo-observe-id` attribute so a
9
+ * later `act`/`run` call can address it by stable numeric `id` instead of a fragile
10
+ * CSS selector or pixel coordinates. Ids are stable until the next `observe()`/
11
+ * navigation, exactly like the real tool's contract.
12
+ */
13
+ import type { Page } from "playwright";
14
+
15
+ // Module-scoped (NOT global) DOM shims: these `page.evaluate()` callbacks run
16
+ // INSIDE the browser page, not in this Bun/Node process, so they need `document`/
17
+ // `HTMLElement` in scope for the callback body to type-check — but a real
18
+ // `/// <reference lib="dom" />` pulls DOM's global `ReadableStream`/`Headers`
19
+ // declarations into the WHOLE program's type-checking and breaks unrelated
20
+ // Bun/Node-side files (job-registry.ts, the anthropic provider, ...) that rely on
21
+ // the Node/Bun versions of those types. `declare const`/`type` inside a module
22
+ // file are scoped to this file only, so they carry no such risk.
23
+ declare const document: any;
24
+ type HTMLElement = any;
25
+ type HTMLInputElement = any;
26
+
27
+ export interface ObservedElement {
28
+ id: number;
29
+ role: string;
30
+ name: string;
31
+ value: string;
32
+ tag: string;
33
+ }
34
+
35
+ const OBSERVE_ATTR = "data-jeo-observe-id";
36
+ const INTERACTIVE_SELECTOR = 'a, button, input, select, textarea, [role], [onclick], [tabindex]';
37
+ const MAX_OBSERVED = 300;
38
+
39
+ export type ExtractFormat = "markdown" | "text" | "html";
40
+
41
+ export interface Tab {
42
+ page: Page;
43
+ observe(): Promise<ObservedElement[]>;
44
+ id(n: number): import("playwright").Locator;
45
+ goto(url: string, opts?: { waitUntil?: "load" | "domcontentloaded" | "networkidle" }): Promise<void>;
46
+ click(selectorOrId: string | number): Promise<void>;
47
+ type(selectorOrId: string | number, text: string): Promise<void>;
48
+ fill(selector: string, value: string): Promise<void>;
49
+ press(key: string, opts?: { selector?: string }): Promise<void>;
50
+ scroll(dx: number, dy: number): Promise<void>;
51
+ select(selector: string, ...values: string[]): Promise<string[]>;
52
+ waitFor(selectorOrMs: string | number): Promise<void>;
53
+ back(): Promise<void>;
54
+ extract(format?: ExtractFormat): Promise<string>;
55
+ screenshot(opts?: { selector?: string; fullPage?: boolean }): Promise<Buffer>;
56
+ evaluate<T>(fn: (...args: any[]) => T, ...args: any[]): Promise<T>;
57
+ }
58
+
59
+ function resolveLocator(page: Page, selectorOrId: string | number) {
60
+ return typeof selectorOrId === "number" ? page.locator(`[${OBSERVE_ATTR}="${selectorOrId}"]`) : page.locator(selectorOrId).first();
61
+ }
62
+
63
+ export function createTab(page: Page): Tab {
64
+ return {
65
+ page,
66
+
67
+ async observe(): Promise<ObservedElement[]> {
68
+ return page.evaluate(
69
+ ({ sel, attr, max }) => {
70
+ const nodes = Array.from(document.querySelectorAll(sel)).slice(0, max) as HTMLElement[];
71
+ return nodes.map((el, i) => {
72
+ el.setAttribute(attr, String(i));
73
+ const tag = el.tagName.toLowerCase();
74
+ const explicitRole = el.getAttribute("role");
75
+ const role = explicitRole || (tag === "a" ? "link" : tag === "button" ? "button" : tag === "input" ? "textbox" : tag === "select" ? "combobox" : tag === "textarea" ? "textbox" : tag);
76
+ const name = el.getAttribute("aria-label") || (el as HTMLInputElement).placeholder || el.textContent?.trim().replace(/\s+/g, " ").slice(0, 200) || "";
77
+ const value = (el as HTMLInputElement).value ?? "";
78
+ return { id: i, role, name, value, tag };
79
+ });
80
+ },
81
+ { sel: INTERACTIVE_SELECTOR, attr: OBSERVE_ATTR, max: MAX_OBSERVED },
82
+ );
83
+ },
84
+
85
+ id(n: number) {
86
+ return page.locator(`[${OBSERVE_ATTR}="${n}"]`);
87
+ },
88
+
89
+ async goto(url, opts) {
90
+ await page.goto(url, { waitUntil: opts?.waitUntil ?? "load" });
91
+ },
92
+
93
+ async click(selectorOrId) {
94
+ await resolveLocator(page, selectorOrId).click();
95
+ },
96
+
97
+ async type(selectorOrId, text) {
98
+ const loc = resolveLocator(page, selectorOrId);
99
+ await loc.fill("");
100
+ await loc.pressSequentially(text);
101
+ },
102
+
103
+ async fill(selector, value) {
104
+ await page.locator(selector).first().fill(value);
105
+ },
106
+
107
+ async press(key, opts) {
108
+ if (opts?.selector) await page.locator(opts.selector).first().press(key);
109
+ else await page.keyboard.press(key);
110
+ },
111
+
112
+ async scroll(dx, dy) {
113
+ await page.mouse.wheel(dx, dy);
114
+ },
115
+
116
+ async select(selector, ...values) {
117
+ return page.locator(selector).first().selectOption(values);
118
+ },
119
+
120
+ async waitFor(selectorOrMs) {
121
+ if (typeof selectorOrMs === "number") await page.waitForTimeout(selectorOrMs);
122
+ else await page.waitForSelector(selectorOrMs);
123
+ },
124
+
125
+ async back() {
126
+ await page.goBack({ waitUntil: "load" });
127
+ },
128
+
129
+ async extract(format: ExtractFormat = "markdown") {
130
+ if (format === "html") return page.content();
131
+ // No readability port for v1 — markdown/text both collapse to the page's
132
+ // rendered plain text (still far more compact/relevant than raw HTML).
133
+ return page.evaluate(() => document.body?.innerText ?? "");
134
+ },
135
+
136
+ async screenshot(opts) {
137
+ if (opts?.selector) return page.locator(opts.selector).first().screenshot();
138
+ return page.screenshot({ fullPage: !!opts?.fullPage });
139
+ },
140
+
141
+ async evaluate(fn, ...args) {
142
+ return page.evaluate(fn as any, ...(args as any));
143
+ },
144
+ };
145
+ }
@@ -0,0 +1,188 @@
1
+ /**
2
+ * `browser` tool — headless Chromium automation via Playwright (gjc `browser`
3
+ * parity — see `browser-session.ts`'s header for the exact scope: headless-only,
4
+ * no profile/CDP/Electron attach, no stealth patches). MUTATING: drives a real
5
+ * browser and can run arbitrary page/host JS via `run`, so it is excluded from
6
+ * read-only subagent roles the same way `bash`/`computer`/`debug` are.
7
+ */
8
+ import * as fs from "node:fs/promises";
9
+ import * as os from "node:os";
10
+ import * as path from "node:path";
11
+ import type { ToolHandler } from "./engine";
12
+ import type { ToolResult } from "./tools";
13
+ import { browserSession, type BrowserSession } from "./browser-session";
14
+ import type { ObservedElement, ExtractFormat } from "./browser-tab";
15
+
16
+ /** One-line protocol description appended to the launch system prompt. */
17
+ export const BROWSER_TOOL_PROTOCOL_LINE =
18
+ `browser {action:"open"|"close"|"run"|"act", name?, url?, viewport?, code?, actions?, all?} — headless ` +
19
+ `Chromium automation (Playwright). 'open' {name, url?} opens/reuses a named tab (name defaults to "main"). ` +
20
+ `'close' {name?, all?} closes one tab or every tab. 'act' {name, actions:[{verb, ...}]} runs structured steps ` +
21
+ `(navigate/click/type/fill/select/press/scroll/back/wait/observe/extract/screenshot) — address elements by ` +
22
+ `numeric 'id' from a prior 'observe' step (preferred) or a CSS 'selector'; prefer 'observe' over 'screenshot' ` +
23
+ `for understanding page state. 'run' {name, code} executes an async function BODY with page/browser/tab/` +
24
+ `display/assert/wait in scope for anything the structured verbs don't cover.`;
25
+
26
+ function err(message: string): ToolResult {
27
+ return { success: false, output: "", error: message };
28
+ }
29
+
30
+ function tabNameOf(args: Record<string, any>): string {
31
+ const n = typeof args.name === "string" ? args.name.trim() : "";
32
+ return n || "main";
33
+ }
34
+
35
+ interface ActStep {
36
+ verb: string;
37
+ [key: string]: unknown;
38
+ }
39
+
40
+ async function runActStep(session: BrowserSession, name: string, step: ActStep): Promise<Record<string, unknown>> {
41
+ const tab = session.get(name);
42
+ const verb = String(step.verb ?? "").trim().toLowerCase();
43
+ try {
44
+ switch (verb) {
45
+ case "navigate": {
46
+ const url = String(step.url ?? "");
47
+ if (!url) return { verb, ok: false, error: "navigate requires 'url'." };
48
+ await tab.goto(url, { waitUntil: step.wait_until as any });
49
+ return { verb, ok: true, url: tab.page.url() };
50
+ }
51
+ case "click": {
52
+ const target = step.id !== undefined ? Number(step.id) : String(step.selector ?? "");
53
+ if (target === "" ) return { verb, ok: false, error: "click requires 'id' or 'selector'." };
54
+ await tab.click(target);
55
+ return { verb, ok: true };
56
+ }
57
+ case "type": {
58
+ const target = step.id !== undefined ? Number(step.id) : String(step.selector ?? "");
59
+ const text = String(step.text ?? "");
60
+ if (target === "") return { verb, ok: false, error: "type requires 'id' or 'selector'." };
61
+ await tab.type(target, text);
62
+ return { verb, ok: true };
63
+ }
64
+ case "fill": {
65
+ const selector = String(step.selector ?? "");
66
+ if (!selector) return { verb, ok: false, error: "fill requires 'selector'." };
67
+ await tab.fill(selector, String(step.value ?? ""));
68
+ return { verb, ok: true };
69
+ }
70
+ case "select": {
71
+ const selector = String(step.selector ?? "");
72
+ if (!selector) return { verb, ok: false, error: "select requires 'selector'." };
73
+ const values = Array.isArray(step.values) ? step.values.map((v: unknown) => String(v)) : [];
74
+ const selected = await tab.select(selector, ...values);
75
+ return { verb, ok: true, selected };
76
+ }
77
+ case "press": {
78
+ const key = String(step.key ?? "");
79
+ if (!key) return { verb, ok: false, error: "press requires 'key'." };
80
+ await tab.press(key, { selector: typeof step.selector === "string" ? step.selector : undefined });
81
+ return { verb, ok: true };
82
+ }
83
+ case "scroll": {
84
+ await tab.scroll(Number(step.dx ?? 0), Number(step.dy ?? 0));
85
+ return { verb, ok: true };
86
+ }
87
+ case "back": {
88
+ await tab.back();
89
+ return { verb, ok: true, url: tab.page.url() };
90
+ }
91
+ case "wait": {
92
+ if (typeof step.ms === "number") await tab.waitFor(step.ms);
93
+ else if (typeof step.selector === "string") await tab.waitFor(step.selector);
94
+ else return { verb, ok: false, error: "wait requires 'selector' or 'ms'." };
95
+ return { verb, ok: true };
96
+ }
97
+ case "observe": {
98
+ const elements: ObservedElement[] = await tab.observe();
99
+ return { verb, ok: true, url: tab.page.url(), title: await tab.page.title(), elements };
100
+ }
101
+ case "extract": {
102
+ const format = (typeof step.format === "string" ? step.format : "markdown") as ExtractFormat;
103
+ const content = await tab.extract(format);
104
+ return { verb, ok: true, content: content.length > 8000 ? content.slice(0, 8000) + "…" : content };
105
+ }
106
+ case "screenshot": {
107
+ const buf = await tab.screenshot({ selector: typeof step.selector === "string" ? step.selector : undefined, fullPage: !!step.fullPage });
108
+ const savePath = path.join(os.tmpdir(), `jeo-browser-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.png`);
109
+ await fs.writeFile(savePath, buf);
110
+ return { verb, ok: true, saved: savePath, bytes: buf.length };
111
+ }
112
+ default:
113
+ return { verb, ok: false, error: `Unknown act verb '${verb}'.` };
114
+ }
115
+ } catch (e: any) {
116
+ return { verb, ok: false, error: e.message ?? String(e) };
117
+ }
118
+ }
119
+
120
+ export function createBrowserTool(): ToolHandler {
121
+ return async (args: Record<string, any>, _cwd: string): Promise<ToolResult> => {
122
+ const action = String(args.action ?? "").trim().toLowerCase();
123
+
124
+ if (action === "open") {
125
+ const name = tabNameOf(args);
126
+ const url = typeof args.url === "string" && args.url.trim() ? args.url.trim() : undefined;
127
+ const viewport = args.viewport && typeof args.viewport === "object"
128
+ ? { width: Number(args.viewport.width) || 1280, height: Number(args.viewport.height) || 800 }
129
+ : undefined;
130
+ try {
131
+ const tab = await browserSession.open(name, url, viewport);
132
+ return { success: true, output: `Tab '${name}' open at ${tab.page.url() || "about:blank"}.` };
133
+ } catch (e: any) {
134
+ return err(`Failed to open tab '${name}': ${e.message ?? e}`);
135
+ }
136
+ }
137
+
138
+ if (action === "close") {
139
+ const all = args.all === true;
140
+ const name = typeof args.name === "string" ? args.name.trim() : undefined;
141
+ if (!all && !name) return err(`browser {action:"close"} requires "name" or "all:true".`);
142
+ const closed = await browserSession.close(name, all);
143
+ return { success: true, output: closed.length ? `Closed: ${closed.join(", ")}.` : "No matching open tab." };
144
+ }
145
+
146
+ if (action === "run") {
147
+ const name = tabNameOf(args);
148
+ const code = typeof args.code === "string" ? args.code : "";
149
+ if (!code.trim()) return err(`browser {action:"run"} requires a non-empty "code" body.`);
150
+ if (!browserSession.has(name)) return err(`No open tab named '${name}'. Call browser {action:"open", name} first.`);
151
+ const tab = browserSession.get(name);
152
+ const displayed: string[] = [];
153
+ const display = (v: unknown) => { displayed.push(typeof v === "string" ? v : JSON.stringify(v, null, 2)); };
154
+ const assert = (cond: unknown, message?: string) => { if (!cond) throw new Error(message ?? "Assertion failed"); };
155
+ const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
156
+ try {
157
+ const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor as new (...a: string[]) => (...a: unknown[]) => Promise<unknown>;
158
+ const fn = new AsyncFunction("page", "browser", "tab", "display", "assert", "wait", code);
159
+ const result = await fn(tab.page, browserSession.browserInstance, tab, display, assert, wait);
160
+ const resultText = result === undefined ? "" : `\n\nReturn value:\n${typeof result === "string" ? result : JSON.stringify(result, null, 2)}`;
161
+ return { success: true, output: `${displayed.join("\n")}${resultText}`.trim() || "(no output)" };
162
+ } catch (e: any) {
163
+ return err(`browser run failed: ${e.message ?? e}`);
164
+ }
165
+ }
166
+
167
+ if (action === "act") {
168
+ const name = tabNameOf(args);
169
+ if (!browserSession.has(name)) {
170
+ try {
171
+ await browserSession.open(name);
172
+ } catch (e: any) {
173
+ return err(`Failed to open tab '${name}': ${e.message ?? e}`);
174
+ }
175
+ }
176
+ const steps = Array.isArray(args.actions) ? args.actions as ActStep[] : [];
177
+ if (steps.length === 0) return err(`browser {action:"act"} requires a non-empty "actions" array.`);
178
+ const results: Record<string, unknown>[] = [];
179
+ for (const step of steps) {
180
+ results.push(await runActStep(browserSession, name, step));
181
+ }
182
+ const allOk = results.every((r) => r.ok === true);
183
+ return { success: allOk, output: JSON.stringify(results, null, 2) };
184
+ }
185
+
186
+ return err(`Unknown browser action '${action}'. Use open | close | run | act.`);
187
+ };
188
+ }