jeo-code 0.7.32 → 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.
- package/CHANGELOG.md +10 -0
- package/README.ja.md +1 -1
- package/README.ko.md +1 -1
- package/README.md +1 -1
- package/README.zh.md +1 -1
- package/package.json +3 -3
- package/src/agent/AGENTS.md +3 -0
- package/src/agent/browser-session.ts +104 -0
- package/src/agent/browser-tab.ts +145 -0
- package/src/agent/browser-tool.ts +188 -0
- package/src/agent/engine.ts +4 -1
- package/src/agent/subagents.ts +1 -1
- package/src/commands/launch.ts +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
6
6
|
|
|
7
7
|
The README mirrors the latest 5 entries — regenerate with `bun run changelog:sync`.
|
|
8
8
|
|
|
9
|
+
## [0.7.33] - 2026-07-04
|
|
10
|
+
_Adds `browser` — headless Chromium automation (gjc `browser` parity) — completing the gjc tool-surface parity pass started in 0.7.32._
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- **`browser` — headless Chromium automation via Playwright (gjc `browser` parity, headless-only for v1 — no existing-profile/CDP-endpoint/Electron attach, no stealth patches).** `browser-session.ts` keeps one shared browser instance alive across calls with named tabs reused ("open once, act many times"); `browser-tab.ts` provides `observe()`-first helpers (tags interactive elements with stable numeric ids instead of a raw DOM dump or a screenshot) plus `click`/`type`/`fill`/`select`/`press`/`scroll`/`goto`/`back`/`extract`/`screenshot`/`evaluate`. The `browser` tool exposes `open`/`close`/`run`/`act`: `act` runs structured steps (navigate/click/type/fill/select/press/scroll/back/wait/observe/extract/screenshot) addressed by numeric `id` (from a prior `observe`) or CSS selector; `run` executes an async-function body with `page`/`browser`/`tab`/`display`/`assert`/`wait` in scope for anything the structured verbs don't cover. Mutating (drives a real browser, `run` executes arbitrary host JS) — excluded from read-only subagent roles. `playwright` moves from `devDependencies` to `dependencies` (it was already bundled for jeo-code's own test suite, so this reuses it rather than adding a second browser-automation engine).
|
|
14
|
+
|
|
15
|
+
### Verified
|
|
16
|
+
- `bun run typecheck` clean; full suite green.
|
|
17
|
+
- New coverage: `test/browser-tool.test.ts` (open/act round-trip against real headless Chromium — observe → type → click → extract, `run` with display/assert, screenshot-to-file, close/close-all, and error-path guards) with `test.skipIf` fallbacks for environments without the Chromium binary downloaded; `test/subagent-toolset-gating.test.ts` extended to assert `browser` is excluded from every read-only role.
|
|
18
|
+
|
|
9
19
|
## [0.7.32] - 2026-07-03
|
|
10
20
|
_gjc tool-surface parity — ast_grep/ast_edit, lsp/lsp_rename, debug, irc, job, and goal tools — plus a `launch.ts` slash-command refactor and four agent-loop guardrail hardenings (destructive-bash denylist, spawn-gate justification quality check, done-verification abuse guard, and a blind unanchored line-range-edit guard)._
|
|
11
21
|
|
package/README.ja.md
CHANGED
|
@@ -207,11 +207,11 @@ CI は `.github/workflows/npm-publish.yml` で公開します — GitHub リリ
|
|
|
207
207
|
## 変更履歴 (Changelog)
|
|
208
208
|
|
|
209
209
|
<!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
|
|
210
|
+
- **[0.7.33]** (2026-07-04) — Adds `browser` — headless Chromium automation (gjc `browser` parity) — completing the gjc tool-surface parity pass started in 0.7.32.
|
|
210
211
|
- **[0.7.32]** (2026-07-03) — gjc tool-surface parity — ast_grep/ast_edit, lsp/lsp_rename, debug, irc, job, and goal tools — plus a `launch.ts` slash-command refactor and four agent-loop guardrail hardenings (destructive-bash denylist, spawn-gate justification quality check, done-verification abuse guard, and a blind unanchored line-range-edit guard).
|
|
211
212
|
- **[0.7.31]** (2026-07-03) — Three-round speed & memory pass across streaming, memory retrieval, and the TUI render loop (O(n²) → O(n) in several hot paths), a deep-interview repeat-question loop guard, and gajae-code managed-tmux launch sizing parity.
|
|
212
213
|
- **[0.7.30]** (2026-07-02) — GPT/Codex OAuth calls work again, and the 30-minute turn budget now limits no-progress stalls instead of killing genuinely progressing autonomous runs.
|
|
213
214
|
- **[0.7.29]** (2026-07-02) — gajae-code safety-refusal retry parity: Anthropic/OAuth content-classifier refusals no longer end the turn with a fatal "declined to answer" message after the context-reset ladder.
|
|
214
|
-
- **[0.7.28]** (2026-07-02) — gajae-code 0.7.9–0.7.10 OAuth + model-execution parity: generic long rate-limit windows are retried (never fatal), retry waits are abort-cancellable, Codex prompt caching via session correlation, and the Kimi Code device-flow OAuth is completed end-to-end (catalog, gating, discovery, tests).
|
|
215
215
|
|
|
216
216
|
See [CHANGELOG.md](CHANGELOG.md) for the full history.
|
|
217
217
|
<!-- CHANGELOG:END -->
|
package/README.ko.md
CHANGED
|
@@ -207,11 +207,11 @@ CI는 `.github/workflows/npm-publish.yml`로 배포합니다 — GitHub 릴리
|
|
|
207
207
|
## 변경 이력 (Changelog)
|
|
208
208
|
|
|
209
209
|
<!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
|
|
210
|
+
- **[0.7.33]** (2026-07-04) — Adds `browser` — headless Chromium automation (gjc `browser` parity) — completing the gjc tool-surface parity pass started in 0.7.32.
|
|
210
211
|
- **[0.7.32]** (2026-07-03) — gjc tool-surface parity — ast_grep/ast_edit, lsp/lsp_rename, debug, irc, job, and goal tools — plus a `launch.ts` slash-command refactor and four agent-loop guardrail hardenings (destructive-bash denylist, spawn-gate justification quality check, done-verification abuse guard, and a blind unanchored line-range-edit guard).
|
|
211
212
|
- **[0.7.31]** (2026-07-03) — Three-round speed & memory pass across streaming, memory retrieval, and the TUI render loop (O(n²) → O(n) in several hot paths), a deep-interview repeat-question loop guard, and gajae-code managed-tmux launch sizing parity.
|
|
212
213
|
- **[0.7.30]** (2026-07-02) — GPT/Codex OAuth calls work again, and the 30-minute turn budget now limits no-progress stalls instead of killing genuinely progressing autonomous runs.
|
|
213
214
|
- **[0.7.29]** (2026-07-02) — gajae-code safety-refusal retry parity: Anthropic/OAuth content-classifier refusals no longer end the turn with a fatal "declined to answer" message after the context-reset ladder.
|
|
214
|
-
- **[0.7.28]** (2026-07-02) — gajae-code 0.7.9–0.7.10 OAuth + model-execution parity: generic long rate-limit windows are retried (never fatal), retry waits are abort-cancellable, Codex prompt caching via session correlation, and the Kimi Code device-flow OAuth is completed end-to-end (catalog, gating, discovery, tests).
|
|
215
215
|
|
|
216
216
|
See [CHANGELOG.md](CHANGELOG.md) for the full history.
|
|
217
217
|
<!-- CHANGELOG:END -->
|
package/README.md
CHANGED
|
@@ -234,11 +234,11 @@ Huge thanks to [gajae-code](https://github.com/Yeachan-Heo/gajae-code) for the i
|
|
|
234
234
|
## Changelog
|
|
235
235
|
|
|
236
236
|
<!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
|
|
237
|
+
- **[0.7.33]** (2026-07-04) — Adds `browser` — headless Chromium automation (gjc `browser` parity) — completing the gjc tool-surface parity pass started in 0.7.32.
|
|
237
238
|
- **[0.7.32]** (2026-07-03) — gjc tool-surface parity — ast_grep/ast_edit, lsp/lsp_rename, debug, irc, job, and goal tools — plus a `launch.ts` slash-command refactor and four agent-loop guardrail hardenings (destructive-bash denylist, spawn-gate justification quality check, done-verification abuse guard, and a blind unanchored line-range-edit guard).
|
|
238
239
|
- **[0.7.31]** (2026-07-03) — Three-round speed & memory pass across streaming, memory retrieval, and the TUI render loop (O(n²) → O(n) in several hot paths), a deep-interview repeat-question loop guard, and gajae-code managed-tmux launch sizing parity.
|
|
239
240
|
- **[0.7.30]** (2026-07-02) — GPT/Codex OAuth calls work again, and the 30-minute turn budget now limits no-progress stalls instead of killing genuinely progressing autonomous runs.
|
|
240
241
|
- **[0.7.29]** (2026-07-02) — gajae-code safety-refusal retry parity: Anthropic/OAuth content-classifier refusals no longer end the turn with a fatal "declined to answer" message after the context-reset ladder.
|
|
241
|
-
- **[0.7.28]** (2026-07-02) — gajae-code 0.7.9–0.7.10 OAuth + model-execution parity: generic long rate-limit windows are retried (never fatal), retry waits are abort-cancellable, Codex prompt caching via session correlation, and the Kimi Code device-flow OAuth is completed end-to-end (catalog, gating, discovery, tests).
|
|
242
242
|
|
|
243
243
|
See [CHANGELOG.md](CHANGELOG.md) for the full history.
|
|
244
244
|
<!-- CHANGELOG:END -->
|
package/README.zh.md
CHANGED
|
@@ -207,11 +207,11 @@ CI 通过 `.github/workflows/npm-publish.yml` 发布 — GitHub 发布 release
|
|
|
207
207
|
## 更新日志 (Changelog)
|
|
208
208
|
|
|
209
209
|
<!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
|
|
210
|
+
- **[0.7.33]** (2026-07-04) — Adds `browser` — headless Chromium automation (gjc `browser` parity) — completing the gjc tool-surface parity pass started in 0.7.32.
|
|
210
211
|
- **[0.7.32]** (2026-07-03) — gjc tool-surface parity — ast_grep/ast_edit, lsp/lsp_rename, debug, irc, job, and goal tools — plus a `launch.ts` slash-command refactor and four agent-loop guardrail hardenings (destructive-bash denylist, spawn-gate justification quality check, done-verification abuse guard, and a blind unanchored line-range-edit guard).
|
|
211
212
|
- **[0.7.31]** (2026-07-03) — Three-round speed & memory pass across streaming, memory retrieval, and the TUI render loop (O(n²) → O(n) in several hot paths), a deep-interview repeat-question loop guard, and gajae-code managed-tmux launch sizing parity.
|
|
212
213
|
- **[0.7.30]** (2026-07-02) — GPT/Codex OAuth calls work again, and the 30-minute turn budget now limits no-progress stalls instead of killing genuinely progressing autonomous runs.
|
|
213
214
|
- **[0.7.29]** (2026-07-02) — gajae-code safety-refusal retry parity: Anthropic/OAuth content-classifier refusals no longer end the turn with a fatal "declined to answer" message after the context-reset ladder.
|
|
214
|
-
- **[0.7.28]** (2026-07-02) — gajae-code 0.7.9–0.7.10 OAuth + model-execution parity: generic long rate-limit windows are retried (never fatal), retry waits are abort-cancellable, Codex prompt caching via session correlation, and the Kimi Code device-flow OAuth is completed end-to-end (catalog, gating, discovery, tests).
|
|
215
215
|
|
|
216
216
|
See [CHANGELOG.md](CHANGELOG.md) for the full history.
|
|
217
217
|
<!-- CHANGELOG:END -->
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "jeo-code",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.33",
|
|
4
4
|
|
|
5
5
|
"description": "Clean, highly optimized AI coding agent using spec-first loop",
|
|
6
6
|
"type": "module",
|
|
@@ -61,11 +61,11 @@
|
|
|
61
61
|
"zod": "^3.24.1",
|
|
62
62
|
"chalk": "^5.3.0",
|
|
63
63
|
"js-tiktoken": "^1.0.21",
|
|
64
|
-
"typescript": "^5.6.3"
|
|
64
|
+
"typescript": "^5.6.3",
|
|
65
|
+
"playwright": "^1.61.0"
|
|
65
66
|
},
|
|
66
67
|
"devDependencies": {
|
|
67
68
|
"@types/bun": "^1.1.14",
|
|
68
|
-
"playwright": "^1.61.0",
|
|
69
69
|
"ws": "^8.21.0"
|
|
70
70
|
},
|
|
71
71
|
"packageManager": "pnpm@10.33.4+sha512.1c67b3b359b2d408119ba1ed289f34b8fc3c6873412bec6fd264fbdc82489e510fcbecb9ce9d22dae7f3b76269d8441046014bdca53b9979cd7a561ad631b800"
|
package/src/agent/AGENTS.md
CHANGED
|
@@ -17,6 +17,9 @@ The core runtime loop, tool registry, session management, and state persistence
|
|
|
17
17
|
| `debug-session.ts` | In-process CDP (V8 Inspector Protocol) client driving a spawned `node --inspect-brk` process (gjc `debug` parity, Node.js only — not Bun, whose inspector speaks a different WebKit dialect); one active session, singleton |
|
|
18
18
|
| `debug-tool.ts` | `debug` tool — launch/set_breakpoint/continue/step/evaluate/stack_trace/scopes/variables/threads/output/terminate via `debug-session.ts`; mutating (runs arbitrary code), excluded from read-only roles |
|
|
19
19
|
| `lsp-rename-tool.ts` | `lsp_rename` tool — cross-file TypeScript/JavaScript rename; mutating, writes via `writeTool`, kept separate from `lsp` so read-only roles can't reach it |
|
|
20
|
+
| `browser-session.ts` | Named-tab headless-Chromium session registry (gjc `browser` parity, via Playwright — already a jeo-code dependency) — one shared browser instance launched lazily, tabs reused by name across calls |
|
|
21
|
+
| `browser-tab.ts` | Per-tab helpers (`observe`/`click`/`type`/`fill`/`select`/`press`/`scroll`/`goto`/`back`/`extract`/`screenshot`/`evaluate`) — `observe()` tags interactive elements with stable numeric ids instead of returning a raw DOM dump or requiring a screenshot |
|
|
22
|
+
| `browser-tool.ts` | `browser` tool — open/close/run/act actions; mutating (drives a real browser, `run` executes arbitrary host JS), excluded from read-only roles |
|
|
20
23
|
| `ast-edit-tool.ts` | `ast_edit` tool — structural codemod using `ast-match.ts`; mutating, writes via `writeTool` |
|
|
21
24
|
| `bash-fixups.ts` | Brief description of purpose |
|
|
22
25
|
| `compaction.ts` | Brief description of purpose |
|
|
@@ -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
|
+
}
|
package/src/agent/engine.ts
CHANGED
|
@@ -21,6 +21,7 @@ import { createAstEditTool } from "./ast-edit-tool";
|
|
|
21
21
|
import { createLspTool } from "./lsp-tool";
|
|
22
22
|
import { createLspRenameTool } from "./lsp-rename-tool";
|
|
23
23
|
import { createDebugTool } from "./debug-tool";
|
|
24
|
+
import { createBrowserTool } from "./browser-tool";
|
|
24
25
|
import { isRateLimitError, waitAbortable } from "../util/retry";
|
|
25
26
|
import { isContextOverflowError, isRefusalError, friendlyProviderError } from "../util/provider-error";
|
|
26
27
|
import { runPreToolHooks, runPostTurnHooksForBatch } from "./hooks";
|
|
@@ -103,6 +104,7 @@ export const DEFAULT_TOOLS: Record<string, ToolHandler> = {
|
|
|
103
104
|
lsp: createLspTool(),
|
|
104
105
|
lsp_rename: createLspRenameTool(),
|
|
105
106
|
debug: createDebugTool(),
|
|
107
|
+
browser: createBrowserTool(),
|
|
106
108
|
};
|
|
107
109
|
/** Tool-protocol description injected into the system prompt. */
|
|
108
110
|
export const TOOL_PROTOCOL = [
|
|
@@ -123,7 +125,8 @@ export const TOOL_PROTOCOL = [
|
|
|
123
125
|
"14. lsp {action, file, line?, symbol?} — TypeScript/JavaScript language-service queries (definition/references/hover/symbols/diagnostics); 'symbol' resolves the column on 'line' (append #N for the Nth occurrence)",
|
|
124
126
|
"15. lsp_rename {file, line, symbol?, new_name, apply?} — cross-file TypeScript/JavaScript rename (apply defaults true; apply:false previews only)",
|
|
125
127
|
"16. debug {action, ...} — Node.js debugging (launch/set_breakpoint/continue/step_over/step_in/step_out/pause/evaluate/stack_trace/scopes/variables/threads/output/terminate); one active session, 'launch' spawns 'node --inspect-brk' and pauses at start",
|
|
126
|
-
"17.
|
|
128
|
+
"17. browser {action:\"open\"|\"close\"|\"run\"|\"act\", name?, url?, actions?} — headless Chromium automation (Playwright); 'act' runs structured steps (navigate/click/type/fill/select/press/scroll/back/wait/observe/extract/screenshot) addressing elements by numeric id from 'observe' or a CSS selector; 'run' executes JS with page/browser/tab/display/assert/wait in scope",
|
|
129
|
+
"18. done {reason?} — call when the task is fully implemented AND verified",
|
|
127
130
|
"",
|
|
128
131
|
"Reply with STRICT JSON only — no code fences. You MAY include an optional leading",
|
|
129
132
|
'"reasoning" string (one short sentence on your plan) before "tool":',
|
package/src/agent/subagents.ts
CHANGED
|
@@ -252,7 +252,7 @@ export function bashCommandAllowed(command: string, prefixes: string[]): boolean
|
|
|
252
252
|
*/
|
|
253
253
|
export function subagentToolset(role: SubagentRole): Record<string, ToolHandler> {
|
|
254
254
|
if (role.readOnly) {
|
|
255
|
-
const MUTATING = new Set(["write", "edit", "bash", "mkdir", "delete", "ast_edit", "computer", "lsp_rename", "debug"]);
|
|
255
|
+
const MUTATING = new Set(["write", "edit", "bash", "mkdir", "delete", "ast_edit", "computer", "lsp_rename", "debug", "browser"]);
|
|
256
256
|
const ro: Record<string, ToolHandler> = {};
|
|
257
257
|
for (const [name, handler] of Object.entries(DEFAULT_TOOLS)) {
|
|
258
258
|
if (MUTATING.has(name)) continue;
|
package/src/commands/launch.ts
CHANGED
|
@@ -525,7 +525,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
525
525
|
// pi-style: load project context (JEO.md / AGENTS.md / .jeo/context.md / CLAUDE.md) into the prompt.
|
|
526
526
|
const contextFiles = await loadProjectContext(cwd);
|
|
527
527
|
|
|
528
|
-
const KNOWN_TOOLS = new Set(["read", "write", "edit", "bash", "find", "search", "ls", "task", "todo", "subagent", "job", "irc", "goal", "ast_grep", "ast_edit", "computer", "lsp", "lsp_rename", "debug"]);
|
|
528
|
+
const KNOWN_TOOLS = new Set(["read", "write", "edit", "bash", "find", "search", "ls", "task", "todo", "subagent", "job", "irc", "goal", "ast_grep", "ast_edit", "computer", "lsp", "lsp_rename", "debug", "browser"]);
|
|
529
529
|
let allowedTools = new Set(KNOWN_TOOLS);
|
|
530
530
|
|
|
531
531
|
if (flags.noTools) {
|