headlesscode 1.0.2

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 (232) hide show
  1. package/ATTRIBUTION.md +53 -0
  2. package/CODE_OF_CONDUCT.md +130 -0
  3. package/CONTRIBUTING.md +107 -0
  4. package/LICENSE +202 -0
  5. package/README.md +486 -0
  6. package/SECURITY.md +211 -0
  7. package/bin/headlesscode.mjs +83 -0
  8. package/package.json +63 -0
  9. package/shared/prompts/review-mode-prompt-short.md +93 -0
  10. package/shared/prompts/review-mode-prompt.md +281 -0
  11. package/shared/rules-code/rules.md +22 -0
  12. package/shared/stacks/cpp/rules.md +30 -0
  13. package/shared/stacks/fastapi/rules.md +30 -0
  14. package/shared/stacks/javascript/rules.md +37 -0
  15. package/shared/stacks/postgresql/rules.md +31 -0
  16. package/shared/stacks/python/rules.md +35 -0
  17. package/shared/stacks/react/rules.md +11 -0
  18. package/shared/stacks/typescript/rules.md +10 -0
  19. package/src/budget/budget.ts +221 -0
  20. package/src/budget/concurrency.ts +126 -0
  21. package/src/budget/cost.ts +309 -0
  22. package/src/budget/index.ts +8 -0
  23. package/src/checkpoints/cli.ts +256 -0
  24. package/src/checkpoints/service.ts +227 -0
  25. package/src/cli.ts +1535 -0
  26. package/src/cloud/docker-provider.ts +334 -0
  27. package/src/cloud/provider.ts +300 -0
  28. package/src/codeintel/call-graph.ts +78 -0
  29. package/src/codeintel/find-references.ts +123 -0
  30. package/src/codeintel/go-to-definition.ts +193 -0
  31. package/src/codeintel/handlers.ts +190 -0
  32. package/src/codeintel/import-graph.ts +173 -0
  33. package/src/codeintel/outline.ts +180 -0
  34. package/src/codeintel/position.ts +77 -0
  35. package/src/codeintel/program.ts +350 -0
  36. package/src/codeintel/rename-symbol.ts +213 -0
  37. package/src/codeintel/tools.ts +280 -0
  38. package/src/codemap/build.ts +135 -0
  39. package/src/codemap/cli.ts +190 -0
  40. package/src/codemap/extract.ts +339 -0
  41. package/src/codemap/files.ts +236 -0
  42. package/src/codemap/fingerprint.ts +65 -0
  43. package/src/codemap/flows.ts +62 -0
  44. package/src/codemap/html.ts +451 -0
  45. package/src/codemap/lock.ts +80 -0
  46. package/src/codemap/types.ts +101 -0
  47. package/src/codesearch/airunner-embedder.ts +185 -0
  48. package/src/codesearch/chunk.ts +339 -0
  49. package/src/codesearch/cli.ts +223 -0
  50. package/src/codesearch/embedder.ts +332 -0
  51. package/src/codesearch/files.ts +280 -0
  52. package/src/codesearch/index.ts +469 -0
  53. package/src/codesearch/ollama-embedder.ts +205 -0
  54. package/src/codesearch/search.ts +141 -0
  55. package/src/codesearch/types.ts +100 -0
  56. package/src/config/mode-models.ts +218 -0
  57. package/src/dashboard/aggregate.ts +364 -0
  58. package/src/dashboard/chat-thread.ts +141 -0
  59. package/src/dashboard/checkpoints.ts +124 -0
  60. package/src/dashboard/cli.ts +193 -0
  61. package/src/dashboard/codemap.ts +44 -0
  62. package/src/dashboard/files.ts +121 -0
  63. package/src/dashboard/page.ts +2803 -0
  64. package/src/dashboard/self-improvement-metrics.ts +282 -0
  65. package/src/dashboard/server.ts +1103 -0
  66. package/src/dashboard/session-launch.ts +310 -0
  67. package/src/dashboard/timeline.ts +273 -0
  68. package/src/dashboard/tool-exec.ts +107 -0
  69. package/src/dashboard/trend-cli.ts +141 -0
  70. package/src/dashboard/trend.ts +413 -0
  71. package/src/decision-proxy/cli.ts +261 -0
  72. package/src/decision-proxy/proxy.ts +569 -0
  73. package/src/deploy/gate-cli.ts +147 -0
  74. package/src/deploy/gate.ts +254 -0
  75. package/src/engine/condense.ts +512 -0
  76. package/src/engine/events.ts +428 -0
  77. package/src/engine/handoff.ts +71 -0
  78. package/src/engine/lazy-tools.ts +160 -0
  79. package/src/engine/local-explore.ts +653 -0
  80. package/src/engine/logger.ts +96 -0
  81. package/src/engine/loop.ts +5517 -0
  82. package/src/engine/parser.ts +347 -0
  83. package/src/engine/prompt.ts +860 -0
  84. package/src/engine/reports.ts +47 -0
  85. package/src/engine/stacks.ts +448 -0
  86. package/src/engine/types.ts +291 -0
  87. package/src/engine/usage.ts +186 -0
  88. package/src/github/app-auth.ts +161 -0
  89. package/src/github/cli.ts +448 -0
  90. package/src/github/installations.ts +133 -0
  91. package/src/github/pr.ts +321 -0
  92. package/src/github/provision.ts +118 -0
  93. package/src/github/push.ts +122 -0
  94. package/src/index-util.ts +50 -0
  95. package/src/index.ts +81 -0
  96. package/src/init/cli.ts +248 -0
  97. package/src/init/gitignore.ts +74 -0
  98. package/src/llm/ollama.ts +308 -0
  99. package/src/llm/openrouter.ts +868 -0
  100. package/src/llm/preflight.ts +367 -0
  101. package/src/llm/transcript-capture.ts +84 -0
  102. package/src/memory/embed.ts +110 -0
  103. package/src/memory/index.ts +22 -0
  104. package/src/memory/local.ts +259 -0
  105. package/src/memory/summarizer.ts +283 -0
  106. package/src/memory/types.ts +153 -0
  107. package/src/memory/uwuchat.ts +157 -0
  108. package/src/migrate/cli.ts +115 -0
  109. package/src/orchestrator/analyze-cli.ts +104 -0
  110. package/src/orchestrator/auto-split.ts +206 -0
  111. package/src/orchestrator/cleanup.ts +1003 -0
  112. package/src/orchestrator/cli.ts +3571 -0
  113. package/src/orchestrator/cost-estimate.ts +564 -0
  114. package/src/orchestrator/cost-history-cli.ts +242 -0
  115. package/src/orchestrator/cost-history.ts +397 -0
  116. package/src/orchestrator/git-sync.ts +250 -0
  117. package/src/orchestrator/index.ts +153 -0
  118. package/src/orchestrator/log-analysis.ts +0 -0
  119. package/src/orchestrator/merge-check.ts +108 -0
  120. package/src/orchestrator/pipeline.ts +411 -0
  121. package/src/orchestrator/resume.ts +1940 -0
  122. package/src/orchestrator/reviewer.ts +503 -0
  123. package/src/orchestrator/split.ts +296 -0
  124. package/src/orchestrator/state.ts +542 -0
  125. package/src/orchestrator/status.ts +697 -0
  126. package/src/orchestrator/verification-gate.ts +134 -0
  127. package/src/orchestrator/watch.ts +898 -0
  128. package/src/permissions/commands.ts +1083 -0
  129. package/src/permissions/config.ts +241 -0
  130. package/src/permissions/index.ts +12 -0
  131. package/src/permissions/protected-files.ts +96 -0
  132. package/src/permissions/store-protection.ts +272 -0
  133. package/src/project-store.ts +648 -0
  134. package/src/projects/cli.ts +382 -0
  135. package/src/qa/qa.ts +487 -0
  136. package/src/tools/browser/handler.ts +346 -0
  137. package/src/tools/browser/service.ts +406 -0
  138. package/src/tools/browser/smoke.ts +78 -0
  139. package/src/tools/browser/tool.ts +99 -0
  140. package/src/tools/executor.ts +2575 -0
  141. package/src/tools/language-detect.ts +183 -0
  142. package/src/tools/output-summarizer.ts +369 -0
  143. package/src/tools/run-tests.ts +302 -0
  144. package/src/tools/set-indentation-tool.ts +49 -0
  145. package/src/tools/test-selection.ts +160 -0
  146. package/src/vendor/tests/smoke.ts +103 -0
  147. package/src/vendor/zoo-code/VENDOR-NOTES.md +213 -0
  148. package/src/vendor/zoo-code/shim/anthropic.ts +71 -0
  149. package/src/vendor/zoo-code/shim/openai.d.ts +60 -0
  150. package/src/vendor/zoo-code/shim/os-name.ts +18 -0
  151. package/src/vendor/zoo-code/shim/strip-bom.ts +14 -0
  152. package/src/vendor/zoo-code/shim/vscode.ts +76 -0
  153. package/src/vendor/zoo-code/src/core/config/CustomModesManager.ts +1015 -0
  154. package/src/vendor/zoo-code/src/core/diff/strategies/multi-search-replace.ts +670 -0
  155. package/src/vendor/zoo-code/src/core/prompts/sections/capabilities.ts +46 -0
  156. package/src/vendor/zoo-code/src/core/prompts/sections/custom-instructions.ts +559 -0
  157. package/src/vendor/zoo-code/src/core/prompts/sections/index.ts +10 -0
  158. package/src/vendor/zoo-code/src/core/prompts/sections/markdown-formatting.ts +7 -0
  159. package/src/vendor/zoo-code/src/core/prompts/sections/modes.ts +35 -0
  160. package/src/vendor/zoo-code/src/core/prompts/sections/objective.ts +13 -0
  161. package/src/vendor/zoo-code/src/core/prompts/sections/rules.ts +95 -0
  162. package/src/vendor/zoo-code/src/core/prompts/sections/skills.ts +105 -0
  163. package/src/vendor/zoo-code/src/core/prompts/sections/system-info.ts +30 -0
  164. package/src/vendor/zoo-code/src/core/prompts/sections/tool-use-guidelines.ts +9 -0
  165. package/src/vendor/zoo-code/src/core/prompts/sections/tool-use.ts +7 -0
  166. package/src/vendor/zoo-code/src/core/prompts/system.ts +176 -0
  167. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/access_mcp_resource.ts +41 -0
  168. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/apply_diff.ts +40 -0
  169. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/apply_patch.ts +61 -0
  170. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/ask_followup_question.ts +62 -0
  171. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/attempt_completion.ts +33 -0
  172. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/codebase_search.ts +43 -0
  173. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/converters.ts +109 -0
  174. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/edit.ts +48 -0
  175. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/edit_file.ts +72 -0
  176. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/execute_command.ts +54 -0
  177. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/generate_image.ts +51 -0
  178. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/index.ts +75 -0
  179. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/list_files.ts +41 -0
  180. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/mcp_server.ts +75 -0
  181. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/new_task.ts +39 -0
  182. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/read_command_output.ts +81 -0
  183. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/read_file.ts +169 -0
  184. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/run_slash_command.ts +31 -0
  185. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/search_files.ts +50 -0
  186. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/search_replace.ts +51 -0
  187. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/skill.ts +33 -0
  188. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/switch_mode.ts +31 -0
  189. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/update_todo_list.ts +54 -0
  190. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/write_to_file.ts +40 -0
  191. package/src/vendor/zoo-code/src/core/prompts/types.ts +12 -0
  192. package/src/vendor/zoo-code/src/i18n/index.ts +19 -0
  193. package/src/vendor/zoo-code/src/integrations/misc/extract-text.ts +81 -0
  194. package/src/vendor/zoo-code/src/services/checkpoints/RepoPerTaskCheckpointService.ts +15 -0
  195. package/src/vendor/zoo-code/src/services/checkpoints/ShadowCheckpointService.ts +553 -0
  196. package/src/vendor/zoo-code/src/services/checkpoints/excludes.ts +212 -0
  197. package/src/vendor/zoo-code/src/services/checkpoints/index.ts +3 -0
  198. package/src/vendor/zoo-code/src/services/checkpoints/types.ts +35 -0
  199. package/src/vendor/zoo-code/src/services/code-index/manager.ts +19 -0
  200. package/src/vendor/zoo-code/src/services/mcp/McpHub.ts +36 -0
  201. package/src/vendor/zoo-code/src/services/roo-config/index.ts +441 -0
  202. package/src/vendor/zoo-code/src/services/search/file-search.ts +143 -0
  203. package/src/vendor/zoo-code/src/services/skills/SkillsManager.ts +20 -0
  204. package/src/vendor/zoo-code/src/shared/globalFileNames.ts +9 -0
  205. package/src/vendor/zoo-code/src/shared/language.ts +43 -0
  206. package/src/vendor/zoo-code/src/shared/modes.ts +257 -0
  207. package/src/vendor/zoo-code/src/shared/tools.ts +385 -0
  208. package/src/vendor/zoo-code/src/utils/fs.ts +39 -0
  209. package/src/vendor/zoo-code/src/utils/globalContext.ts +22 -0
  210. package/src/vendor/zoo-code/src/utils/json-schema.ts +16 -0
  211. package/src/vendor/zoo-code/src/utils/logging.ts +21 -0
  212. package/src/vendor/zoo-code/src/utils/mcp-name.ts +190 -0
  213. package/src/vendor/zoo-code/src/utils/object.ts +18 -0
  214. package/src/vendor/zoo-code/src/utils/path.ts +94 -0
  215. package/src/vendor/zoo-code/src/utils/shell.ts +376 -0
  216. package/src/vendor/zoo-code/src/utils/text-normalization.ts +99 -0
  217. package/src/vendor/zoo-code/types/global-settings.ts +19 -0
  218. package/src/vendor/zoo-code/types/index.ts +22 -0
  219. package/src/vendor/zoo-code/types/message.ts +375 -0
  220. package/src/vendor/zoo-code/types/mode.ts +241 -0
  221. package/src/vendor/zoo-code/types/todo.ts +19 -0
  222. package/src/vendor/zoo-code/types/tool-params.ts +116 -0
  223. package/src/vendor/zoo-code/types/tool.ts +67 -0
  224. package/src/vendor/zoo-code/types/vscode.ts +84 -0
  225. package/src/vision/describe.ts +242 -0
  226. package/src/vision/tool.ts +91 -0
  227. package/src/watcher/cli.ts +369 -0
  228. package/src/watcher/github.ts +304 -0
  229. package/src/watcher/index.ts +59 -0
  230. package/src/watcher/state.ts +254 -0
  231. package/src/watcher/watch.ts +562 -0
  232. package/tsconfig.json +18 -0
@@ -0,0 +1,406 @@
1
+ /**
2
+ * Playwright-backed browser session service for the `browser_action` native
3
+ * tool (see src/tools/browser/tool.ts for the tool-facing contract).
4
+ *
5
+ * This is NEW design work — a browser-automation tool for the coding agent to
6
+ * inspect a running dev server mid-session. It is NOT a port of any upstream
7
+ * Zoo Code feature (verified: no browser tool exists in the vendored core or
8
+ * the reference clone; `docs/gap-audit.md`'s "No browser tool" line is a real
9
+ * absence). The QA-side Playwright runner in a sibling project is a different,
10
+ * report-generation concern; only the Playwright dependency choice is shared.
11
+ *
12
+ * Design decisions, matching the harness's own idioms:
13
+ *
14
+ * - One BrowserSession lives for the whole session, created lazily on the
15
+ * first `launch` action. Playwright's Chromium launches ~1s; it is the one
16
+ * browser that is guaranteed present in this repo's dev environment (the
17
+ * sibling project's QA runner already uses it, and its browsers are
18
+ * cached), so it needs no per-machine install guesswork.
19
+ * - `getConsoleLogs`/`getNetworkErrors` are polled by the executor: the
20
+ * session keeps a rolling buffer of page console entries / failed requests
21
+ * (console and requestfailed are capture-time events; Playwright exposes no
22
+ * "past events" API), and each call drains the buffer so repeated calls
23
+ * return only NEW entries. Network-error collection deliberately does NOT
24
+ * use Playwright's request interception (route()), which would abort real
25
+ * requests and change page behavior — see collectNetworkErrors.
26
+ * - Each browser action runs under an overall wall-clock timeout
27
+ * (DEFAULT_BROWSER_ACTION_TIMEOUT_MS) that also covers the launch/browser
28
+ * teardown. A stuck browser action must surface a clear tool error, never
29
+ * hang the session.
30
+ */
31
+
32
+ import { chromium, type Browser, type BrowserContext, type Page } from "playwright"
33
+
34
+ /** Cap on the number of console/network entries returned per call. */
35
+ export const MAX_BROWSER_EVENT_ENTRIES = 200
36
+
37
+ /** Overall per-action timeout (ms) — see the file header. */
38
+ export const DEFAULT_BROWSER_ACTION_TIMEOUT_MS = 20_000
39
+
40
+ /**
41
+ * Timeout for a single click/type actionability wait (ms). Kept BELOW the
42
+ * overall action timeout so a missing selector surfaces Playwright's precise
43
+ * "waiting for locator" error instead of the generic action-timeout message.
44
+ */
45
+ export const BROWSER_INTERACTION_TIMEOUT_MS = 15_000
46
+
47
+ /** Cap on screenshot bytes so tool results stay bounded (see tool.ts). */
48
+ export const MAX_SCREENSHOT_BYTES = 2 * 1024 * 1024
49
+
50
+ /** A single captured console entry (page console, incl. errors). */
51
+ export interface ConsoleEntry {
52
+ type: "log" | "info" | "warn" | "error" | "debug" | "trace" | string
53
+ text: string
54
+ ts: number
55
+ }
56
+
57
+ /** A single captured network failure (page requestfailed events). */
58
+ export interface NetworkErrorEntry {
59
+ url: string
60
+ failure: string
61
+ method: string
62
+ ts: number
63
+ }
64
+
65
+ /** A single captured page navigation (page.on("framenavigated")). */
66
+ export interface NavigationEntry {
67
+ url: string
68
+ ts: number
69
+ }
70
+
71
+ /**
72
+ * Lifecycle + per-page instrumentation state. A fresh BrowserSession is a
73
+ * null browser — the browser process is spawned lazily on the first `launch`
74
+ * and must be torn down on session end even if the model never calls close.
75
+ */
76
+ export class BrowserSession {
77
+ private browser: Browser | null = null
78
+ private context: BrowserContext | null = null
79
+ private page: Page | null = null
80
+ private consoleEntries: ConsoleEntry[] = []
81
+ private networkErrors: NetworkErrorEntry[] = []
82
+ private navigations: NavigationEntry[] = []
83
+ private pageClosed = false
84
+ private lastUrl: string | null = null
85
+
86
+ /** True once launch() created the browser; false before that / after close(). */
87
+ get isLaunched(): boolean {
88
+ return this.browser !== null
89
+ }
90
+
91
+ /** The current page URL (null before launch or after the page was closed). */
92
+ get currentUrl(): string | null {
93
+ return this.lastUrl
94
+ }
95
+
96
+ /** Total console entries captured since launch (drained per getConsoleLogs call). */
97
+ get consoleCount(): number {
98
+ return this.consoleEntries.length
99
+ }
100
+
101
+ /** Total network errors captured since launch (drained per getNetworkErrors call). */
102
+ get networkErrorCount(): number {
103
+ return this.networkErrors.length
104
+ }
105
+
106
+ /**
107
+ * Launch a headless browser and navigate to `url`. Multiple launch calls
108
+ * are allowed: a subsequent launch closes the previous browser (with all
109
+ * its state) and starts fresh — the model's explicit reset path.
110
+ */
111
+ async launch(url: string): Promise<void> {
112
+ // Fresh browser: reset the closed flag (close()/dispose() set it, and a
113
+ // re-launch must be able to open a new page).
114
+ this.pageClosed = false
115
+ this.browser = await chromium.launch({ headless: true })
116
+ this.context = await this.browser.newContext()
117
+ this.page = await this.context.newPage()
118
+ this.attachListeners()
119
+ await this.goto(url)
120
+ }
121
+
122
+ /** Navigate to `url` and wait for the page to load (default 30s). */
123
+ async goto(url: string): Promise<void> {
124
+ if (this.page === null || this.pageClosed) {
125
+ throw new Error("browser_action: no open page — call launch(url) first")
126
+ }
127
+ this.lastUrl = url
128
+ try {
129
+ // waitUntil: "load" matches the QA runner's default and the vendored
130
+ // ChromeDevTools "navigate" semantics (page fully loaded). Network
131
+ // failures (DNS, refused) reject with an ERR_* error.
132
+ await this.page.goto(url, { waitUntil: "load", timeout: 30_000 })
133
+ } catch (error) {
134
+ // Capture-time navigation (see the "page navigated to X" result) is
135
+ // only pushed on success; a failed navigation must not mislead the
136
+ // model into thinking the page is showing. Let the error propagate —
137
+ // the executor wraps it in a clear tool error.
138
+ throw error
139
+ }
140
+ }
141
+
142
+ /**
143
+ * Screenshot the current page as base64 PNG. The executor converts this to
144
+ * a PNG file written inside the workspace, because tool results here are
145
+ * text-only (ToolResult.content is a string — no image content part exists
146
+ * in this harness's ChatMessage plumbing; see the report).
147
+ */
148
+ async screenshot(): Promise<string> {
149
+ if (this.page === null || this.pageClosed) {
150
+ throw new Error("browser_action: no open page — call launch(url) first")
151
+ }
152
+ const buffer = await this.page.screenshot({ type: "png", fullPage: true })
153
+ if (buffer.length > MAX_SCREENSHOT_BYTES) {
154
+ throw new Error(`browser_action: screenshot is ${buffer.length} bytes — above the ${MAX_SCREENSHOT_BYTES}-byte cap`)
155
+ }
156
+ return buffer.toString("base64")
157
+ }
158
+
159
+ /**
160
+ * Click the first element matching `selector`. The candidate is queued for
161
+ * up to 30 seconds (the Playwright default actionability wait): a dev
162
+ * server may still be mounting when the model clicks. If no match appears,
163
+ * this rejects with a clear "selector did not resolve" error.
164
+ */
165
+ async click(selector: string): Promise<void> {
166
+ await this.withPage(async (page) => {
167
+ const locator = page.locator(selector).first()
168
+ await locator.click({ timeout: BROWSER_INTERACTION_TIMEOUT_MS })
169
+ })
170
+ }
171
+
172
+ /** Type `text` into the first element matching `selector` (must be focusable). */
173
+ async type(selector: string, text: string): Promise<void> {
174
+ await this.withPage(async (page) => {
175
+ const locator = page.locator(selector).first()
176
+ await locator.fill(text, { timeout: BROWSER_INTERACTION_TIMEOUT_MS })
177
+ })
178
+ }
179
+
180
+ /**
181
+ * Drain captured console entries since the last call (capture-time events —
182
+ * there is no "past events" API in Playwright). Returns the NEW entries.
183
+ */
184
+ async getConsoleLogs(): Promise<ConsoleEntry[]> {
185
+ const entries = this.consoleEntries
186
+ this.consoleEntries = []
187
+ return entries
188
+ }
189
+
190
+ /** Drain captured network failures since the last call (see file header). */
191
+ async getNetworkErrors(): Promise<NetworkErrorEntry[]> {
192
+ const errors = this.networkErrors
193
+ this.networkErrors = []
194
+ return errors
195
+ }
196
+
197
+ /**
198
+ * Drain captured navigations since the last call. Used to surface the
199
+ * landing URL / link navigation results to the model.
200
+ */
201
+ async getNavigations(): Promise<NavigationEntry[]> {
202
+ const navigations = this.navigations
203
+ this.navigations = []
204
+ return navigations
205
+ }
206
+
207
+ /** Current page state summary — what the model sees after every action. */
208
+ async describe(): Promise<string> {
209
+ if (this.page === null || this.pageClosed) {
210
+ return "browser: no open page"
211
+ }
212
+ const title = await this.page.title()
213
+ const url = this.page.url()
214
+ this.lastUrl = url
215
+ return `browser: ${title} — ${url}`
216
+ }
217
+
218
+ /**
219
+ * Wait for the current page to be quiet (no pending network requests for
220
+ * 300ms). Used by navigation/screenshot/click/type to surface the settled
221
+ * URL after the action, and by the dev-server smoke test.
222
+ */
223
+ async waitForIdle(timeoutMs = 5_000): Promise<void> {
224
+ if (this.page === null || this.pageClosed) {
225
+ return
226
+ }
227
+ const idleTimeout = 300
228
+ try {
229
+ await this.page.waitForLoadState("networkidle", { timeout: timeoutMs })
230
+ } catch {
231
+ // Timeout: treat as "still some polling traffic", NOT a failure —
232
+ // a live dev server may legitimately keep a websocket open.
233
+ }
234
+ // Extra settle beat so capture-time events (console, failures) land
235
+ // before the caller drains them.
236
+ await new Promise((r) => setTimeout(r, idleTimeout))
237
+ }
238
+
239
+ /**
240
+ * Hard-close the browser (the model's explicit cleanup). Playwright's
241
+ * browser.close() kills the child processes; any residual process-group
242
+ * member is killed too (see killBrowserProcessGroup). Idempotent.
243
+ *
244
+ * browser.close() can itself hang if the browser process is wedged, so it
245
+ * runs under a short timeout — teardown must never block the session
246
+ * (killBrowserProcessGroup() is the guaranteed kill after it).
247
+ */
248
+ async close(): Promise<void> {
249
+ const browser = this.browser
250
+ this.browser = null
251
+ this.context = null
252
+ this.page = null
253
+ this.pageClosed = true
254
+ this.consoleEntries = []
255
+ this.networkErrors = []
256
+ this.navigations = []
257
+ if (browser !== null) {
258
+ let timer: NodeJS.Timeout | undefined
259
+ const timeout = new Promise<never>((_, reject) => {
260
+ timer = setTimeout(() => reject(new Error("browser.close() timed out — forcing process kill")), 5_000)
261
+ })
262
+ try {
263
+ await Promise.race([browser.close(), timeout])
264
+ } catch {
265
+ // Browser already gone, or close hung — the process-group kill
266
+ // (dispose) is the backstop; close() alone is best-effort.
267
+ } finally {
268
+ clearTimeout(timer)
269
+ }
270
+ }
271
+ }
272
+
273
+ /**
274
+ * Session-end teardown: close the browser AND hard-kill the Chromium
275
+ * process group (the executable is spawned as a detached group leader —
276
+ * see killBrowserProcessGroup), so nothing survives the session. This is
277
+ * the mirror of ToolExecutor.dispose()'s background-command reaping.
278
+ */
279
+ async dispose(): Promise<void> {
280
+ await this.close()
281
+ killBrowserProcessGroup()
282
+ }
283
+
284
+ /** Run `fn` against the live page, rejecting cleanly when none is open. */
285
+ private async withPage<T>(fn: (page: Page) => Promise<T>): Promise<T> {
286
+ if (this.page === null || this.pageClosed) {
287
+ throw new Error("browser_action: no open page — call launch(url) first")
288
+ }
289
+ const page = this.page
290
+ try {
291
+ return await fn(page)
292
+ } catch (error) {
293
+ // Map a navigation-triggering click to a "page navigated" info
294
+ // instead of an error, then rethrow genuine action failures.
295
+ if (error instanceof Error && /navigation|navigated|target closed|net::/i.test(error.message) && this.browser !== null) {
296
+ throw new Error(`browser_action: the action caused a navigation or the page closed (${error.message})`)
297
+ }
298
+ throw error
299
+ }
300
+ }
301
+
302
+ /** Wire capture-time listeners to the current page. */
303
+ private attachListeners(): void {
304
+ const page = this.page
305
+ if (page === null) {
306
+ return
307
+ }
308
+ page.on("console", (msg) => {
309
+ this.consoleEntries.push({ type: msg.type(), text: msg.text(), ts: Date.now() })
310
+ })
311
+ page.on("requestfailed", (request) => {
312
+ const failure = request.failure()
313
+ this.networkErrors.push({
314
+ url: request.url(),
315
+ failure: failure?.errorText ?? "unknown failure",
316
+ method: request.method(),
317
+ ts: Date.now(),
318
+ })
319
+ })
320
+ // HTTP >= 400 responses are failures for the model's purposes (a 404
321
+ // asset, a 500 API) even though the request itself "succeeded" on the
322
+ // wire, and Playwright only emits `requestfailed` for network-level
323
+ // failures. Track request/response so we can surface both.
324
+ page.on("response", (response) => {
325
+ const status = response.status()
326
+ if (status >= 400) {
327
+ this.networkErrors.push({
328
+ url: response.url(),
329
+ failure: `HTTP ${status}`,
330
+ method: response.request().method(),
331
+ ts: Date.now(),
332
+ })
333
+ }
334
+ })
335
+ page.on("framenavigated", (frame) => {
336
+ if (frame === page.mainFrame()) {
337
+ this.navigations.push({ url: frame.url(), ts: Date.now() })
338
+ }
339
+ })
340
+ page.on("close", () => {
341
+ this.pageClosed = true
342
+ })
343
+ }
344
+ }
345
+
346
+ // ─── Process-group kill for residual Chromium children ──────────────────────
347
+
348
+ /**
349
+ * Chromium PIDs spawned by this module, tracked so session teardown can
350
+ * hard-kill the whole process group (negative pid) even if the browser
351
+ * process itself already exited. This mirrors the execute_command background
352
+ * child registry (see src/tools/executor.ts) — the one place this module
353
+ * deliberately reaches outside the BrowserSession abstraction, because the
354
+ * browser's own close() must never be the ONLY cleanup path.
355
+ */
356
+ const browserProcessPids = new Set<number>()
357
+
358
+ /** Record a spawned Chromium process for group-kill cleanup (see above). */
359
+ export function trackBrowserProcess(pid: number): void {
360
+ browserProcessPids.add(pid)
361
+ }
362
+
363
+ /** Unregister a Chromium pid that exited on its own. */
364
+ export function untrackBrowserProcess(pid: number): void {
365
+ browserProcessPids.delete(pid)
366
+ }
367
+
368
+ /**
369
+ * SIGKILL every tracked Chromium process group. Detached groups are killed
370
+ * with `process.kill(-pid)` so grandchildren (renderer/GPU/network processes)
371
+ * are reaped too, falling back to the direct pid where group signals are
372
+ * unsupported — exactly the dispose() pattern in src/tools/executor.ts.
373
+ */
374
+ export function killBrowserProcessGroup(): void {
375
+ for (const pid of browserProcessPids) {
376
+ try {
377
+ process.kill(-pid, "SIGKILL")
378
+ } catch {
379
+ try {
380
+ process.kill(pid, "SIGKILL")
381
+ } catch {
382
+ // Already gone — nothing to clean up.
383
+ }
384
+ }
385
+ }
386
+ browserProcessPids.clear()
387
+ }
388
+
389
+ // ─── Module-level idempotent hook: register the process-group kill ───────────
390
+
391
+ let disposeHookInstalled = false
392
+
393
+ /**
394
+ * Idempotently register `killBrowserProcessGroup` as a process exit hook so
395
+ * the browser never outlives the harness process, even when a session is torn
396
+ * down by a path that skips ToolExecutor.dispose() (e.g. process.exit during
397
+ * startup). The per-session dispose() path above is the primary cleanup; this
398
+ * is a belt-and-suspenders guarantee.
399
+ */
400
+ export function installBrowserDisposeHook(): void {
401
+ if (disposeHookInstalled) {
402
+ return
403
+ }
404
+ disposeHookInstalled = true
405
+ process.on("exit", killBrowserProcessGroup)
406
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Manual smoke test: drive the REAL browser_action tool against the REAL
3
+ * `headlesscode dashboard` dev server (http://127.0.0.1:4390) — the spec's
4
+ * recommended real target. Exercises launch / screenshot / click / type /
5
+ * getConsoleLogs / getNetworkErrors / close end-to-end, printing the tool
6
+ * results so they can be pasted into the report.
7
+ *
8
+ * Run: node ./node_modules/tsx/dist/cli.mjs src/tools/browser/smoke.ts
9
+ * Requires the dashboard to be running on 127.0.0.1:4390 first.
10
+ */
11
+
12
+ import * as os from "node:os"
13
+ import * as path from "node:path"
14
+ import * as fs from "node:fs/promises"
15
+
16
+ import { createHeadlessExecutor } from "../executor.js"
17
+
18
+ // Keep screenshots under a stable dir in the CURRENT workspace so the report
19
+ // can reference/attach them (the browser tool writes them under the executor's
20
+ // workspace root; for the smoke test that's a temp dir, so we copy them out).
21
+ const ws = await fs.mkdtemp(path.join(os.tmpdir(), "hc-browser-smoke-"))
22
+ const OUT_DIR = path.join(process.cwd(), ".smoke-artifacts")
23
+ await fs.mkdir(OUT_DIR, { recursive: true })
24
+ const executor = createHeadlessExecutor(ws)
25
+ const DASH = "http://127.0.0.1:4390/"
26
+
27
+ async function copyScreenshot(resultContent: string, name: string): Promise<void> {
28
+ const m = resultContent.match(/saved to [^(]+\(([^)]+\.png)\)/)
29
+ if (!m) return
30
+ await fs.copyFile(m[1], path.join(OUT_DIR, name))
31
+ console.log(` (screenshot copied to .smoke-artifacts/${name})`)
32
+ }
33
+
34
+ try {
35
+ console.log("=== 1. launch the real dashboard ===")
36
+ let r = await executor.execute("browser_action", { action: "launch", url: DASH })
37
+ console.log(r.content)
38
+
39
+ console.log("\n=== 2. screenshot the dashboard (PNG saved to workspace) ===")
40
+ r = await executor.execute("browser_action", { action: "screenshot" })
41
+ console.log(r.content)
42
+ await copyScreenshot(r.content, "dashboard-1.png")
43
+
44
+ console.log("\n=== 3. console logs (dashboard page) ===")
45
+ r = await executor.execute("browser_action", { action: "getConsoleLogs" })
46
+ console.log(r.content)
47
+
48
+ console.log("\n=== 4. network errors ===")
49
+ r = await executor.execute("browser_action", { action: "getNetworkErrors" })
50
+ console.log(r.content)
51
+
52
+ console.log("\n=== 5. type a task into the launch textarea (#launchTask) ===")
53
+ r = await executor.execute("browser_action", { action: "type", selector: "#launchTask", text: "verify browser_action smoke test" })
54
+ console.log(r.content)
55
+
56
+ console.log("\n=== 6. screenshot again (shows the typed text in the UI) ===")
57
+ r = await executor.execute("browser_action", { action: "screenshot" })
58
+ console.log(r.content)
59
+ await copyScreenshot(r.content, "dashboard-2-typed.png")
60
+
61
+ console.log("\n=== 6b. click the ▶ Start button (empty task -> validation error in #launchMsg) ===")
62
+ r = await executor.execute("browser_action", { action: "click", selector: "#btnStart" })
63
+ console.log(r.content)
64
+ await new Promise((res) => setTimeout(res, 500))
65
+ console.log("\n=== 6c. console logs after click (page JS response) ===")
66
+ r = await executor.execute("browser_action", { action: "getConsoleLogs" })
67
+ console.log(r.content)
68
+
69
+ console.log("\n=== 7. close the browser ===")
70
+ r = await executor.execute("browser_action", { action: "close" })
71
+ console.log(r.content)
72
+
73
+ console.log("\n=== 8. session teardown (dispose) ===")
74
+ await executor.dispose()
75
+ console.log("dispose() completed — browser torn down at session end")
76
+ } finally {
77
+ await fs.rm(ws, { recursive: true, force: true })
78
+ }
@@ -0,0 +1,99 @@
1
+ /**
2
+ * `browser_action` — a real browser-automation native tool for the coding
3
+ * agent, backed by Playwright (headless Chromium).
4
+ *
5
+ * NEW design work, NOT a port: no browser tool exists in the upstream Zoo
6
+ * Code sources this project vendors (re-verified: `grep -rli browser` in
7
+ * `zoo-code/src/core/tools/` and `zoo-code/src/core/prompts/tools/
8
+ * native-tools/` finds nothing). The only browser precedent in this
9
+ * project's orbit is QA-side (a sibling project's `qa/runner.js` +
10
+ * `browserInstrumentation.js`),
11
+ * which generates structured QA reports — a different concern from this
12
+ * interactive mid-session inspection tool. The Playwright dependency is the
13
+ * one thing shared, deliberately.
14
+ *
15
+ * Scope (deliberately minimal, no full browser DSL): launch / screenshot /
16
+ * click / type / getConsoleLogs / getNetworkErrors / close. No arbitrary
17
+ * Playwright API passthrough.
18
+ *
19
+ * Image handling: ToolResult (src/engine/types.ts) carries only a string
20
+ * `content` — there is NO image-content part in this harness's
21
+ * ChatMessage/tool-result plumbing, and the pinned primary model
22
+ * (deepseek/deepseek-v4-flash) cannot process images at all. Screenshots are
23
+ * therefore described by the cloud vision captioner (src/vision/describe.ts):
24
+ * the PNG is saved inside the workspace, sent to an OpenRouter vision model,
25
+ * and the resulting TEXT description is embedded directly in the tool result
26
+ * (cost flows through the session's BudgetTracker via onAuxLlmUsage). The raw
27
+ * image never reaches the main model — but the file path is kept in the
28
+ * result too, for callers that want the PNG itself.
29
+ *
30
+ * The schema's `action` union is shared with the handler in
31
+ * src/tools/browser/handler.ts, which owns the actual session lifecycle and
32
+ * timeout enforcement.
33
+ */
34
+
35
+ import type OpenAI from "openai"
36
+
37
+ export const BROWSER_ACTION_NAME = "browser_action"
38
+
39
+ const BROWSER_ACTION_DESCRIPTION = `Request to control a headless Chromium browser (via Playwright) to inspect a running web app / dev server and verify UI changes visually. The browser starts on the first launch call and stays alive for the rest of the session; it is torn down automatically when the session ends, so you do not need to call close() for cleanup (but may, to free memory).
40
+
41
+ This tool is for VERIFICATION — a browser is stateful: check the page with screenshot, then interact with click/type, then screenshot again. Do not use it to run shell commands (use execute_command for that).
42
+
43
+ Parameters:
44
+ - action: (required) One of:
45
+ - "launch": Start the headless browser and navigate to url. Re-launching later closes the previous browser and starts fresh.
46
+ - "screenshot": Capture the current page as a PNG. The PNG is written to a file under the workspace (path: <workspaceRoot>/.headlesscode/browser-screenshots/<timestamp>.png) and then described by a cloud vision model — the result includes a real TEXT description of the page (visible text, layout, error messages, UI state) plus the file path + current page title/URL. No raw image reaches the model; act on the description directly.
47
+ - "click": Click the first element matching selector (a CSS selector).
48
+ - "type": Type text into the first element matching selector (a CSS selector; the element must be focusable — a text input or textarea).
49
+ - "getConsoleLogs": Return console messages (including errors) captured from the page since the last getConsoleLogs call (each call returns only NEW entries).
50
+ - "getNetworkErrors": Return failed network requests captured since the last getNetworkErrors call (each call returns only NEW entries).
51
+ - "close": Shut down the browser.
52
+ - url: (required for "launch") The URL to open — typically a local dev server you started with execute_command, e.g. http://127.0.0.1:4390.
53
+ - selector: (required for "click" and "type") A CSS selector.
54
+ - text: (required for "type") The text to type into the element.
55
+
56
+ Examples:
57
+ - Inspect a local dev server: { "action": "launch", "url": "http://127.0.0.1:4390" }
58
+ - See the page: { "action": "screenshot" }
59
+ - Click a button: { "action": "click", "selector": "#btnStart" }
60
+ - Fill a field: { "action": "type", "selector": "#launchTask", "text": "check the UI" }
61
+ - Check for page errors: { "action": "getConsoleLogs" }`
62
+
63
+ const ACTION_PARAMETER_DESCRIPTION = `The browser action to perform: launch | screenshot | click | type | getConsoleLogs | getNetworkErrors | close`
64
+ const URL_PARAMETER_DESCRIPTION = `URL to navigate to (required for launch)`
65
+ const SELECTOR_PARAMETER_DESCRIPTION = `CSS selector for the element to click or type into (required for click/type)`
66
+ const TEXT_PARAMETER_DESCRIPTION = `Text to type into the element (required for type)`
67
+
68
+ export const browserActionTool = {
69
+ type: "function",
70
+ function: {
71
+ name: BROWSER_ACTION_NAME,
72
+ description: BROWSER_ACTION_DESCRIPTION,
73
+ strict: true,
74
+ parameters: {
75
+ type: "object",
76
+ properties: {
77
+ action: {
78
+ type: "string",
79
+ description: ACTION_PARAMETER_DESCRIPTION,
80
+ enum: ["launch", "screenshot", "click", "type", "getConsoleLogs", "getNetworkErrors", "close"],
81
+ },
82
+ url: {
83
+ type: ["string", "null"],
84
+ description: URL_PARAMETER_DESCRIPTION,
85
+ },
86
+ selector: {
87
+ type: ["string", "null"],
88
+ description: SELECTOR_PARAMETER_DESCRIPTION,
89
+ },
90
+ text: {
91
+ type: ["string", "null"],
92
+ description: TEXT_PARAMETER_DESCRIPTION,
93
+ },
94
+ },
95
+ required: ["action", "url", "selector", "text"],
96
+ additionalProperties: false,
97
+ },
98
+ },
99
+ } satisfies OpenAI.Chat.ChatCompletionTool