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,346 @@
1
+ /**
2
+ * `browser_action` handler: the session lifecycle + timeout enforcement.
3
+ *
4
+ * Every browser action runs under an overall wall-clock timeout
5
+ * (DEFAULT_BROWSER_ACTION_TIMEOUT_MS) using Promise.race — a hung or crashed
6
+ * browser action surfaces as a clear tool error and NEVER hangs the session.
7
+ * The timeout kills (rather than backgrounds) the stuck action: unlike a
8
+ * timed-out execute_command (which the model intentionally backgrounds to
9
+ * keep a dev server running), a stuck browser action is never something the
10
+ * model asked to keep running — it is a failure of the action itself.
11
+ *
12
+ * Session lifecycle mirrors the harness's non-fatal/cleanup idioms:
13
+ * - The browser is created lazily on the first launch and stays alive for
14
+ * the session (a dev server inspection span multiple calls).
15
+ * - ToolExecutor.dispose() calls BrowserSession.dispose() (via
16
+ * disposeBrowserSessions) so the browser is torn down at session end even
17
+ * if the model never calls close() — same discipline as the backgrounded
18
+ * execute_command children (see src/tools/executor.ts).
19
+ * - A launched browser is also hard-killed via the module-level process
20
+ * exit hook (installBrowserDisposeHook in service.ts), belt-and-suspenders
21
+ * for teardown paths that skip dispose().
22
+ *
23
+ * Screenshot results are written to `<workspaceRoot>/.headlesscode/
24
+ * browser-screenshots/<timestamp>.png` and the PNG is immediately described by
25
+ * the cloud vision captioner (src/vision/describe.ts), so the tool result
26
+ * carries a real TEXT description the main model can act on — no raw image
27
+ * ever reaches it. The file path stays in the result too, for callers that
28
+ * want the raw PNG.
29
+ */
30
+
31
+ import * as fsp from "node:fs/promises"
32
+ import * as path from "node:path"
33
+ import { setTimeout as sleep } from "node:timers/promises"
34
+
35
+ import type { AuxLlmUsage, ToolContext, ToolResult } from "../../engine/types.js"
36
+ import { resolveWithinWorkspace } from "../executor.js"
37
+ import { describeImage, type DescribeImageResult } from "../../vision/describe.js"
38
+ import {
39
+ BrowserSession,
40
+ MAX_BROWSER_EVENT_ENTRIES,
41
+ DEFAULT_BROWSER_ACTION_TIMEOUT_MS,
42
+ installBrowserDisposeHook,
43
+ } from "./service.js"
44
+ import { browserActionTool, BROWSER_ACTION_NAME } from "./tool.js"
45
+
46
+ export { browserActionTool, BROWSER_ACTION_NAME }
47
+
48
+ /** Subdirectory (under the workspace root) where screenshots are written. */
49
+ const SCREENSHOTS_SUBDIR = ".headlesscode/browser-screenshots"
50
+
51
+ // ─── Session registry ────────────────────────────────────────────────────────
52
+
53
+ /**
54
+ * Active browser sessions, keyed by workspace root. One ToolExecutor serves
55
+ * one session, and the browser may outlive a single tool call (that is the
56
+ * point — an interactive inspection spans multiple browser_action calls), so
57
+ * the session lives at module scope and ToolExecutor.dispose() tears it down
58
+ * at true session end. Keying by workspace root keeps independent sessions
59
+ * (e.g. a QA executor in the same process) from sharing a browser.
60
+ */
61
+ const browserSessions = new Map<string, BrowserSession>()
62
+
63
+ function sessionFor(ctx: ToolContext): BrowserSession {
64
+ let session = browserSessions.get(ctx.workspaceRoot)
65
+ if (session === undefined) {
66
+ session = new BrowserSession()
67
+ browserSessions.set(ctx.workspaceRoot, session)
68
+ }
69
+ return session
70
+ }
71
+
72
+ /**
73
+ * Session teardown for every known browser session: hard-close the browser
74
+ * AND kill the residual Chromium process group (see BrowserSession.dispose).
75
+ * Called from ToolExecutor.dispose(), so a launched browser never survives
76
+ * its session even when the model never calls close().
77
+ */
78
+ export function disposeBrowserSessions(): void {
79
+ for (const session of browserSessions.values()) {
80
+ void session.dispose()
81
+ }
82
+ browserSessions.clear()
83
+ }
84
+
85
+ // ─── Arg validation ──────────────────────────────────────────────────────────
86
+
87
+ export type BrowserAction =
88
+ | "launch"
89
+ | "screenshot"
90
+ | "click"
91
+ | "type"
92
+ | "getConsoleLogs"
93
+ | "getNetworkErrors"
94
+ | "close"
95
+
96
+ function requireStringArg(args: Record<string, unknown>, key: string): string {
97
+ const v = args[key]
98
+ if (typeof v !== "string" || v.trim() === "") {
99
+ throw new Error(`browser_action: missing or invalid string argument '${key}' (got ${JSON.stringify(v)})`)
100
+ }
101
+ return v
102
+ }
103
+
104
+ const VALID_ACTIONS: readonly BrowserAction[] = [
105
+ "launch",
106
+ "screenshot",
107
+ "click",
108
+ "type",
109
+ "getConsoleLogs",
110
+ "getNetworkErrors",
111
+ "close",
112
+ ]
113
+
114
+ function parseAction(args: Record<string, unknown>): BrowserAction {
115
+ const raw = args.action
116
+ const action = typeof raw === "string" ? raw : ""
117
+ if (!(VALID_ACTIONS as readonly string[]).includes(action)) {
118
+ throw new Error(
119
+ `browser_action: invalid action '${raw}'. Valid actions: ${VALID_ACTIONS.join(", ")}`,
120
+ )
121
+ }
122
+ return action as BrowserAction
123
+ }
124
+
125
+ // ─── Timeout enforcement ─────────────────────────────────────────────────────
126
+
127
+ /**
128
+ * Run a browser action under an overall wall-clock timeout. The action's
129
+ * Playwright internals already carry per-step timeouts; this is the outer
130
+ * bound that catches the "whole browser hung" case (e.g. the page never
131
+ * settles, the browser crashed mid-action). We KILL on timeout rather than
132
+ * background: unlike execute_command, nothing about a stuck browser action is
133
+ * legitimately long-running work the model asked for.
134
+ */
135
+ async function withActionTimeout<T>(fn: () => Promise<T>): Promise<T> {
136
+ // Env override so tests can exercise the timeout fast; production default
137
+ // is DEFAULT_BROWSER_ACTION_TIMEOUT_MS.
138
+ const timeoutMs = Number(process.env.BROWSER_ACTION_TIMEOUT_MS ?? "") || DEFAULT_BROWSER_ACTION_TIMEOUT_MS
139
+ let timer: NodeJS.Timeout | undefined
140
+ const timeout = new Promise<never>((_, reject) => {
141
+ timer = setTimeout(() => {
142
+ reject(
143
+ new Error(
144
+ `browser_action: action timed out after ${timeoutMs / 1000}s — ` +
145
+ `the browser or page appears hung. Call close() (or let the session end) to reset.`,
146
+ ),
147
+ )
148
+ }, timeoutMs)
149
+ })
150
+ try {
151
+ return await Promise.race([fn(), timeout])
152
+ } finally {
153
+ clearTimeout(timer)
154
+ }
155
+ }
156
+
157
+ // ─── Formatting helpers ──────────────────────────────────────────────────────
158
+
159
+ function formatConsoleEntries(entries: Array<{ type: string; text: string }>): string {
160
+ if (entries.length === 0) {
161
+ return "no console messages captured since the last check"
162
+ }
163
+ const lines = entries.map((e) => `[${e.type}] ${e.text}`)
164
+ return lines.join("\n")
165
+ }
166
+
167
+ function formatNetworkErrors(entries: Array<{ url: string; failure: string; method: string }>): string {
168
+ if (entries.length === 0) {
169
+ return "no failed network requests captured since the last check"
170
+ }
171
+ const lines = entries.map((e) => `${e.method} ${e.url} — ${e.failure}`)
172
+ return lines.join("\n")
173
+ }
174
+
175
+ function ok(content: string): ToolResult {
176
+ return { content, isError: false }
177
+ }
178
+
179
+ function err(content: string): ToolResult {
180
+ return { content: `[Error] ${content}`, isError: true }
181
+ }
182
+
183
+ /**
184
+ * Resolve a screenshot target path inside the workspace and ensure the
185
+ * screenshots directory exists.
186
+ */
187
+ async function resolveScreenshotPath(workspaceRoot: string): Promise<string> {
188
+ const dir = resolveWithinWorkspace(workspaceRoot, SCREENSHOTS_SUBDIR)
189
+ await fsp.mkdir(dir, { recursive: true })
190
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-")
191
+ return path.join(dir, `screenshot-${stamp}.png`)
192
+ }
193
+
194
+ // ─── Handler ─────────────────────────────────────────────────────────────────
195
+
196
+ /**
197
+ * The screenshot action's captioner, injectable for tests. Real sessions
198
+ * always use describeImage (src/vision/describe.ts); browser-action.test.ts
199
+ * swaps in a fake so the suite needs no network/API key.
200
+ */
201
+ let visionCaptioner: (imagePath: string) => Promise<DescribeImageResult> = (imagePath) => describeImage(imagePath)
202
+
203
+ /** Test seam: replace the screenshot captioner (see browser-action.test.ts). */
204
+ export function setVisionCaptionerForTest(
205
+ captioner: (imagePath: string) => Promise<DescribeImageResult>,
206
+ ): void {
207
+ visionCaptioner = captioner
208
+ }
209
+
210
+ export async function browserActionHandler(
211
+ args: Record<string, unknown>,
212
+ ctx: ToolContext,
213
+ ): Promise<ToolResult> {
214
+ const action = parseAction(args)
215
+
216
+ // A closed browser is not a hard error — it is the session's normal
217
+ // "explicitly cleaned up" state, so a model that calls close() then
218
+ // re-launches gets a clean result, not an error that counts as a mistake.
219
+ if (action !== "launch") {
220
+ const session = browserSessions.get(ctx.workspaceRoot)
221
+ if (session === undefined || !session.isLaunched) {
222
+ return ok("browser: no browser is open — call launch(url) first to start one")
223
+ }
224
+ }
225
+
226
+ // Module-level process-exit cleanup, idempotent; ensures the browser never
227
+ // outlives the harness process even on teardown paths that skip dispose().
228
+ installBrowserDisposeHook()
229
+
230
+ try {
231
+ return await withActionTimeout(async () => {
232
+ switch (action) {
233
+ case "launch": {
234
+ const url = requireStringArg(args, "url")
235
+ const session = sessionFor(ctx)
236
+ // Re-launch is an explicit reset: close the previous browser
237
+ // first so its state never leaks into the fresh session.
238
+ await session.dispose()
239
+ await session.launch(url)
240
+ // Let the page settle so capture-time console/network events
241
+ // are recorded before the model drains them.
242
+ await session.waitForIdle()
243
+ return ok(await session.describe())
244
+ }
245
+
246
+ case "screenshot": {
247
+ const session = sessionFor(ctx)
248
+ const base64 = await session.screenshot()
249
+ const target = await resolveScreenshotPath(ctx.workspaceRoot)
250
+ await fsp.writeFile(target, Buffer.from(base64, "base64"))
251
+ const desc = await session.describe()
252
+ // Cloud vision captioning: the PNG never reaches the main
253
+ // model (deepseek-v4-flash is text-only), so describe it
254
+ // here and put a real text description in the result. Only
255
+ // when a real accounting session wired onAuxLlmUsage (cost
256
+ // flows through BudgetTracker); bare/read-only executors
257
+ // skip the call and keep the path so the model can still
258
+ // invoke describe_image explicitly.
259
+ let captionNote = ""
260
+ if (ctx.onAuxLlmUsage !== undefined) {
261
+ try {
262
+ const { description, usage } = await visionCaptioner(target)
263
+ ctx.onAuxLlmUsage(usage)
264
+ captionNote = `\nimage description: ${description}`
265
+ } catch (err) {
266
+ // Captioning is a bonus on top of the screenshot —
267
+ // a vision failure must never fail the screenshot
268
+ // itself, just say so and keep the file path.
269
+ captionNote = `\n[image description failed: ${err instanceof Error ? err.message : String(err)}]`
270
+ }
271
+ }
272
+ return ok(
273
+ `${desc}\nscreenshot saved to ${path.relative(ctx.workspaceRoot, target).toPosix()} (${target})${captionNote}`,
274
+ )
275
+ }
276
+
277
+ case "click": {
278
+ const selector = requireStringArg(args, "selector")
279
+ const session = sessionFor(ctx)
280
+ await session.click(selector)
281
+ // Interaction may have triggered a navigation or console
282
+ // errors — settle, then report the current state.
283
+ await session.waitForIdle()
284
+ const navigations = await session.getNavigations()
285
+ const navNote =
286
+ navigations.length > 0
287
+ ? `\npage navigated to: ${navigations.map((n) => n.url).join(", ")}`
288
+ : ""
289
+ return ok(`${await session.describe()}\nclicked ${selector}${navNote}`)
290
+ }
291
+
292
+ case "type": {
293
+ const selector = requireStringArg(args, "selector")
294
+ const text = requireStringArg(args, "text")
295
+ const session = sessionFor(ctx)
296
+ await session.type(selector, text)
297
+ return ok(`${await session.describe()}\ntyped text into ${selector}`)
298
+ }
299
+
300
+ case "getConsoleLogs": {
301
+ const session = sessionFor(ctx)
302
+ const entries = await session.getConsoleLogs()
303
+ const shown = entries.slice(0, MAX_BROWSER_EVENT_ENTRIES)
304
+ const truncated =
305
+ entries.length > MAX_BROWSER_EVENT_ENTRIES
306
+ ? `\n…${entries.length - MAX_BROWSER_EVENT_ENTRIES} more entries (capped at ${MAX_BROWSER_EVENT_ENTRIES})`
307
+ : ""
308
+ return ok(formatConsoleEntries(shown) + truncated)
309
+ }
310
+
311
+ case "getNetworkErrors": {
312
+ const session = sessionFor(ctx)
313
+ const entries = await session.getNetworkErrors()
314
+ const shown = entries.slice(0, MAX_BROWSER_EVENT_ENTRIES)
315
+ const truncated =
316
+ entries.length > MAX_BROWSER_EVENT_ENTRIES
317
+ ? `\n…${entries.length - MAX_BROWSER_EVENT_ENTRIES} more entries (capped at ${MAX_BROWSER_EVENT_ENTRIES})`
318
+ : ""
319
+ return ok(formatNetworkErrors(shown) + truncated)
320
+ }
321
+
322
+ case "close": {
323
+ const session = sessionFor(ctx)
324
+ await session.close()
325
+ return ok("browser: closed")
326
+ }
327
+ }
328
+ })
329
+ } catch (error) {
330
+ const message = error instanceof Error ? error.message : String(error)
331
+ // A crashed/hung browser must never take the whole session down with
332
+ // it: tear it down (awaiting, so a subsequent launch starts from a
333
+ // deterministic closed state) so the model can recover with a fresh
334
+ // launch.
335
+ try {
336
+ await browserSessions.get(ctx.workspaceRoot)?.dispose()
337
+ } catch {
338
+ // Best-effort teardown — the original error is the one to report.
339
+ }
340
+ return err(message)
341
+ }
342
+ }
343
+
344
+ // Reference the schema export so bundlers/type-checkers keep it reachable
345
+ // from this module (it is also re-exported above).
346
+ void browserActionTool