jeo-code 0.7.32 → 0.7.35

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 CHANGED
@@ -6,6 +6,40 @@ 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.35] - 2026-07-04
10
+ _`jeo team` now runs every plan step through the SAME subagent execution core as the `task`/`subagent` tools, and gains gjc-style concurrent workers: a plan can mark a contiguous run of independent steps to execute in parallel, each isolated in its own git worktree, merged back in order._
11
+
12
+ ### Added
13
+ - **`parallel_group` — opt-in concurrent plan-step execution for `jeo team` (gjc `team` concurrency parity), git-worktree isolated.** `src/agent/plan.ts`'s `StepSchema` gains an optional `parallel_group: string` field; `PlanSchema` validates that steps sharing the same non-empty value are mutually CONTIGUOUS (a split group has no coherent execution position and is rejected with a clear Zod issue). `jeo team`'s executor (`runParallelGroup` in `src/commands/team.ts`) dispatches a contiguous group concurrently: each step gets its own git worktree (via the existing `resolveWorktree`, same helper `jeo launch --worktree` uses) and runs through `new SubagentRegistry()` + `runSubagentOnce()` — the identical core the `task`/`subagent` tools and the serial team path use. On full group success, each worker's committed branch is merged back into the current branch IN ARRAY ORDER (`git merge --no-ff`); any step failure OR merge conflict stops immediately, aborts any in-flight merge (`git merge --abort`, never auto-resolved), and leaves every remaining worktree/branch untouched for manual inspection. `jeo ralplan`'s plan-generation prompt now documents `parallel_group` (off by default; only for steps touching disjoint files with no cross-step dependency) so the field is reachable from the normal spec-first workflow, not just hand-authored plans.
14
+
15
+ ### Changed
16
+ - **`jeo team`'s step executor now runs through the SAME execution core as the `task`/`subagent` tools instead of a second hand-rolled copy of the subagent loop.** `runSubagentOnce` (new, exported from `src/agent/task-tool.ts`) is the extracted single execution core — it builds the role's history, runs `runAgentLoop`, validates the done-reason contract, audits mutation evidence, and fences the report against prompt injection — used by `task-tool.ts`'s single/fan-out/detached paths AND `jeo team`'s `executeTaskWithAgent`/`runParallelGroup`. `SubagentRunResult` (a superset of `ToolResult` exposing the raw done reason, contract-ok flag, and mutation-audit counters) lets `jeo team` keep its own role-gate-verdict parsing (architect BLOCK / critic REQUEST CHANGES) and `--strict-mutations` policy on top without re-deriving them from formatted report text. A previously team-only LLM-summarization compaction pass (`maybeCompact`) is no longer called — team subagents now rely solely on the agent loop's own built-in context guard, the same as every other subagent, instead of a second, divergent compaction strategy.
17
+
18
+ ### Verified
19
+ - `bun run typecheck` clean; full suite green (2282 tests).
20
+ - New coverage: `test/team-parallel.test.ts` (real git operations in throwaway temp repos — no git mocking — covering a successful 2-step concurrent group merging both branches, a failing step leaving the group unmerged with the succeeding step's worktree intact, and a genuine merge conflict aborting cleanly with no partial state) plus `PlanSchema` contiguity validation cases. `test/team-run.test.ts` updated for the consolidated execution core's report formatting (contract-incomplete detail now embedded in the fenced report; the obsolete `maybeCompact`-invocation test was removed as an intentional behavior change, not a regression).
21
+
22
+ ## [0.7.34] - 2026-07-04
23
+ _gajae-code 0.8.0/0.8.1 parity audit: closes a real cross-provider lone-surrogate JSON bug and makes turn-boundary compaction Esc-cancellable; the rest of gjc's 0.8.x release (Telegram daemon, ACP, cmux, coordinator MCP, tool-discovery-mode, an internal paged transcript viewport) is gjc-specific architecture jeo intentionally does not replicate, or was already-equivalent (confirmed by a 4-way parallel audit: ast_grep formatting, session-picker empty-filter clamping, PageUp/PageDown)._
24
+
25
+ ### Fixed
26
+ - **A lone (unpaired) UTF-16 surrogate anywhere in tool-call arguments, tool-result output, or message content could break an outgoing provider request (gjc 0.8.1 parity).** Truncating tool output by UTF-16 code units (`tool-output.ts`/`output-util.ts`) can split an emoji's surrogate pair; the orphaned half survived `JSON.stringify` as an unpaired `\ud8xx`-style escape that strict provider JSON parsers reject (e.g. Anthropic 400 "no low surrogate in string"). New `src/util/sanitize-json.ts` (`sanitizeJsonStrings`, a `WeakMap`-guarded recursive walker applying `String.prototype.toWellFormed()` to every string value and object key, ported from gajae-code's shared sanitizer) is now applied at every provider's terminal request-body `JSON.stringify` — Anthropic, Gemini (+ Gemini CLI), OpenAI, OpenAI Responses/Codex, Antigravity, and Ollama.
27
+ - **Turn-boundary compaction could not be cancelled with Esc/Ctrl+C (gjc 0.8.0 "Escape now reliably cancels active context maintenance" parity).** `maybeCompact`'s preflight call in `runTurn` ran before the turn's main abort harness existed, so a slow/hung compaction summarization LLM call had no cancellation path. It now runs under a short-lived abort harness (disposed in a `finally` immediately after) whose signal is threaded into `maybeCompact`, without changing when or how the main per-turn harness is created.
28
+
29
+ ### Verified
30
+ - `bun run typecheck` clean; full suite green.
31
+ - New coverage: `test/sanitize-json.test.ts` (lone high/low surrogate values, a well-formed emoji pair preserved unchanged, nested-object/array-element/object-key surrogates, reference-cycle safety, and an end-to-end Anthropic request-body round-trip); `test/compaction.test.ts` extended with a mid-flight abort case and a structural guard on the `launch.ts` call site's signal wiring.
32
+
33
+ ## [0.7.33] - 2026-07-04
34
+ _Adds `browser` — headless Chromium automation (gjc `browser` parity) — completing the gjc tool-surface parity pass started in 0.7.32._
35
+
36
+ ### Added
37
+ - **`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).
38
+
39
+ ### Verified
40
+ - `bun run typecheck` clean; full suite green.
41
+ - 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.
42
+
9
43
  ## [0.7.32] - 2026-07-03
10
44
  _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
45
 
@@ -31,7 +65,6 @@ _A guardrail audit of the agent loop, subagent spawn gate, and file-edit tools t
31
65
 
32
66
  ## [0.7.31] - 2026-07-03
33
67
  _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._
34
-
35
68
  ### Performance
36
69
  - **Bash tool output streaming no longer accumulates without bound.** `drainPipe` (`src/agent/tools.ts`) stops growing its buffer once it reaches the `bashTool` truncation cap and keeps only a small rolling 16KB tail for the live view afterward — a chatty long-running command no longer holds hundreds of MB in memory for the whole timeout window, and the live `onChunk` callback receives a bounded string instead of the full cumulative buffer (was making every downstream sanitize/wrap pass O(n²) over stream length).
37
70
  - **Engine stream emission is throttled (~80ms) instead of firing per token.** `onModelStream`/`onReasoningStream` in `src/agent/engine.ts` no longer re-flatten the growing response rope and re-scan it in the TUI on every streamed token; a guaranteed final flush (`flushStreams()`) delivers the complete text before parse/commit so no content is ever lost to the throttle. Per-step call-signature digests are now computed once and reused across the repeat/cycle/step-budget novelty checks instead of being re-hashed three times.
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.35]** (2026-07-04) — `jeo team` now runs every plan step through the SAME subagent execution core as the `task`/`subagent` tools, and gains gjc-style concurrent workers: a plan can mark a contiguous run of independent steps to execute in parallel, each isolated in its own git worktree, merged back in order.
211
+ - **[0.7.34]** (2026-07-04) — gajae-code 0.8.0/0.8.1 parity audit: closes a real cross-provider lone-surrogate JSON bug and makes turn-boundary compaction Esc-cancellable; the rest of gjc's 0.8.x release (Telegram daemon, ACP, cmux, coordinator MCP, tool-discovery-mode, an internal paged transcript viewport) is gjc-specific architecture jeo intentionally does not replicate, or was already-equivalent (confirmed by a 4-way parallel audit: ast_grep formatting, session-picker empty-filter clamping, PageUp/PageDown).
212
+ - **[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
213
  - **[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
214
  - **[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
- - **[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
- - **[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.35]** (2026-07-04) — `jeo team` now runs every plan step through the SAME subagent execution core as the `task`/`subagent` tools, and gains gjc-style concurrent workers: a plan can mark a contiguous run of independent steps to execute in parallel, each isolated in its own git worktree, merged back in order.
211
+ - **[0.7.34]** (2026-07-04) — gajae-code 0.8.0/0.8.1 parity audit: closes a real cross-provider lone-surrogate JSON bug and makes turn-boundary compaction Esc-cancellable; the rest of gjc's 0.8.x release (Telegram daemon, ACP, cmux, coordinator MCP, tool-discovery-mode, an internal paged transcript viewport) is gjc-specific architecture jeo intentionally does not replicate, or was already-equivalent (confirmed by a 4-way parallel audit: ast_grep formatting, session-picker empty-filter clamping, PageUp/PageDown).
212
+ - **[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
213
  - **[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
214
  - **[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
- - **[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
- - **[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.35]** (2026-07-04) — `jeo team` now runs every plan step through the SAME subagent execution core as the `task`/`subagent` tools, and gains gjc-style concurrent workers: a plan can mark a contiguous run of independent steps to execute in parallel, each isolated in its own git worktree, merged back in order.
238
+ - **[0.7.34]** (2026-07-04) — gajae-code 0.8.0/0.8.1 parity audit: closes a real cross-provider lone-surrogate JSON bug and makes turn-boundary compaction Esc-cancellable; the rest of gjc's 0.8.x release (Telegram daemon, ACP, cmux, coordinator MCP, tool-discovery-mode, an internal paged transcript viewport) is gjc-specific architecture jeo intentionally does not replicate, or was already-equivalent (confirmed by a 4-way parallel audit: ast_grep formatting, session-picker empty-filter clamping, PageUp/PageDown).
239
+ - **[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
240
  - **[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
241
  - **[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
- - **[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
- - **[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.35]** (2026-07-04) — `jeo team` now runs every plan step through the SAME subagent execution core as the `task`/`subagent` tools, and gains gjc-style concurrent workers: a plan can mark a contiguous run of independent steps to execute in parallel, each isolated in its own git worktree, merged back in order.
211
+ - **[0.7.34]** (2026-07-04) — gajae-code 0.8.0/0.8.1 parity audit: closes a real cross-provider lone-surrogate JSON bug and makes turn-boundary compaction Esc-cancellable; the rest of gjc's 0.8.x release (Telegram daemon, ACP, cmux, coordinator MCP, tool-discovery-mode, an internal paged transcript viewport) is gjc-specific architecture jeo intentionally does not replicate, or was already-equivalent (confirmed by a 4-way parallel audit: ast_grep formatting, session-picker empty-filter clamping, PageUp/PageDown).
212
+ - **[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
213
  - **[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
214
  - **[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
- - **[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
- - **[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.32",
3
+ "version": "0.7.35",
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"
@@ -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 |
@@ -45,7 +48,7 @@ The core runtime loop, tool registry, session management, and state persistence
45
48
  | `subagent-registry.ts` | Brief description of purpose |
46
49
  | `subagent-tool.ts` | Brief description of purpose |
47
50
  | `subagents.ts` | Brief description of purpose |
48
- | `task-tool.ts` | Brief description of purpose |
51
+ | `task-tool.ts` | `task` tool (single/fan-out/detached delegation) — exports `runSubagentOnce`, the single subagent execution core (history/runAgentLoop/contract validation/mutation audit/fenced report) shared with `jeo team`'s executor in `src/commands/team.ts` |
49
52
  | `todo-tool.ts` | Brief description of purpose |
50
53
  | `tokenizer.ts` | Brief description of purpose |
51
54
  | `tool-output.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
+ }
@@ -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. done {reason?} — call when the task is fully implemented AND verified",
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":',