explorbot 0.2.2 → 0.2.4

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 (214) hide show
  1. package/README.md +1 -1
  2. package/bin/explorbot-cli.ts +59 -38
  3. package/boat/api-tester/src/apibot.ts +4 -2
  4. package/boat/api-tester/src/cli.ts +2 -2
  5. package/boat/api-tester/src/config.ts +43 -10
  6. package/boat/doc-collector/bin/doc-collector-cli.ts +2 -0
  7. package/boat/doc-collector/src/ai/documentarian.ts +61 -31
  8. package/boat/doc-collector/src/cli.ts +1 -0
  9. package/boat/doc-collector/src/config.ts +4 -2
  10. package/boat/doc-collector/src/docs-renderer.ts +18 -4
  11. package/boat/doc-collector/src/state-diagram.ts +61 -14
  12. package/boat/prima/bin/prima-cli.ts +5 -0
  13. package/boat/prima/package.json +16 -0
  14. package/boat/prima/src/activity-line.ts +33 -0
  15. package/boat/prima/src/cli.ts +251 -0
  16. package/boat/prima/src/envelope.ts +169 -0
  17. package/boat/prima/src/prima.ts +1124 -0
  18. package/boat/prima/src/pw-parser.ts +27 -0
  19. package/boat/prima/src/pw-registry.ts +74 -0
  20. package/boat/prima/src/session-log.ts +126 -0
  21. package/dist/bin/explorbot-cli.js +51 -32
  22. package/dist/boat/api-tester/bin/apibot-cli.js +2 -0
  23. package/dist/boat/api-tester/src/apibot.js +3 -2
  24. package/dist/boat/api-tester/src/cli.js +2 -2
  25. package/dist/boat/api-tester/src/config.js +40 -10
  26. package/dist/boat/doc-collector/bin/doc-collector-cli.js +2 -0
  27. package/dist/boat/doc-collector/src/ai/documentarian.js +44 -19
  28. package/dist/boat/doc-collector/src/cli.js +1 -0
  29. package/dist/boat/doc-collector/src/config.js +4 -2
  30. package/dist/boat/doc-collector/src/docs-renderer.js +17 -3
  31. package/dist/boat/doc-collector/src/state-diagram.js +57 -13
  32. package/dist/boat/prima/bin/prima-cli.js +4 -0
  33. package/dist/boat/prima/src/activity-line.js +30 -0
  34. package/dist/boat/prima/src/cli.js +220 -0
  35. package/dist/boat/prima/src/envelope.js +142 -0
  36. package/dist/boat/prima/src/prima.js +1031 -0
  37. package/dist/boat/prima/src/pw-parser.js +30 -0
  38. package/dist/boat/prima/src/pw-registry.js +65 -0
  39. package/dist/boat/prima/src/session-log.js +108 -0
  40. package/dist/models.json +3 -0
  41. package/dist/package.json +8 -3
  42. package/dist/rules/navigator/verification-actions.md +20 -0
  43. package/dist/src/action-result.d.ts +7 -0
  44. package/dist/src/action-result.js +4 -0
  45. package/dist/src/action.d.ts +7 -2
  46. package/dist/src/action.js +41 -6
  47. package/dist/src/ai/captain/mixin.js +3 -4
  48. package/dist/src/ai/captain/web-mode.js +7 -4
  49. package/dist/src/ai/captain.js +2 -0
  50. package/dist/src/ai/navigator.d.ts +11 -0
  51. package/dist/src/ai/navigator.js +21 -12
  52. package/dist/src/ai/pilot.d.ts +4 -0
  53. package/dist/src/ai/pilot.js +48 -2
  54. package/dist/src/ai/planner.d.ts +1 -0
  55. package/dist/src/ai/planner.js +6 -0
  56. package/dist/src/ai/provider.js +2 -2
  57. package/dist/src/ai/rerunner.js +2 -1
  58. package/dist/src/ai/researcher.js +2 -2
  59. package/dist/src/ai/rules.js +17 -10
  60. package/dist/src/ai/task-agent.js +5 -2
  61. package/dist/src/ai/tester.d.ts +1 -0
  62. package/dist/src/ai/tester.js +33 -20
  63. package/dist/src/ai/tools.d.ts +14 -0
  64. package/dist/src/ai/tools.js +113 -36
  65. package/dist/src/application-spec-contract.d.ts +8 -0
  66. package/dist/src/application-spec-contract.js +8 -0
  67. package/dist/src/application-spec.d.ts +15 -0
  68. package/dist/src/application-spec.js +71 -0
  69. package/dist/src/browser-server.d.ts +12 -6
  70. package/dist/src/browser-server.js +74 -19
  71. package/dist/src/commands/clean-command.js +2 -7
  72. package/dist/src/commands/init-command.d.ts +5 -0
  73. package/dist/src/commands/init-command.js +119 -1
  74. package/dist/src/commands/navigate-command.js +1 -1
  75. package/dist/src/commands/research-command.js +1 -1
  76. package/dist/src/commands/sites-command.d.ts +6 -0
  77. package/dist/src/commands/sites-command.js +23 -0
  78. package/dist/src/components/InitWizard.d.ts +10 -0
  79. package/dist/src/components/InitWizard.js +133 -0
  80. package/dist/src/components/InputReadline.d.ts +1 -0
  81. package/dist/src/components/InputReadline.js +7 -4
  82. package/dist/src/config.d.ts +25 -5
  83. package/dist/src/config.js +158 -40
  84. package/dist/src/execution-controller.d.ts +2 -0
  85. package/dist/src/execution-controller.js +6 -0
  86. package/dist/src/explorbot.d.ts +11 -1
  87. package/dist/src/explorbot.js +27 -6
  88. package/dist/src/explorer.d.ts +4 -1
  89. package/dist/src/explorer.js +42 -9
  90. package/dist/src/global-config.d.ts +22 -0
  91. package/dist/src/global-config.js +117 -0
  92. package/dist/src/knowledge-tracker.d.ts +5 -1
  93. package/dist/src/knowledge-tracker.js +14 -1
  94. package/dist/src/playwright-recorder.js +30 -0
  95. package/dist/src/remote.d.ts +54 -0
  96. package/dist/src/remote.js +229 -0
  97. package/dist/src/reporter.d.ts +1 -0
  98. package/dist/src/reporter.js +7 -1
  99. package/dist/src/state-manager.d.ts +2 -1
  100. package/dist/src/stats.d.ts +1 -0
  101. package/dist/src/stats.js +1 -0
  102. package/dist/src/test-plan.d.ts +2 -0
  103. package/dist/src/test-plan.js +7 -0
  104. package/dist/src/utils/aria.d.ts +2 -8
  105. package/dist/src/utils/aria.js +69 -40
  106. package/dist/src/utils/cli-name.js +6 -2
  107. package/dist/src/utils/html.js +1 -0
  108. package/dist/src/utils/logger.d.ts +6 -0
  109. package/dist/src/utils/logger.js +24 -0
  110. package/dist/src/utils/page-readiness.js +18 -1
  111. package/dist/src/utils/test-files.js +1 -2
  112. package/dist/src/utils/url-matcher.d.ts +1 -0
  113. package/dist/src/utils/url-matcher.js +12 -0
  114. package/dist/src/utils/web-element.d.ts +2 -0
  115. package/dist/src/utils/web-element.js +8 -0
  116. package/dist/src/utils/web-sandbox.d.ts +1 -1
  117. package/dist/src/utils/web-sandbox.js +2 -3
  118. package/docs/api-testing/basics.md +90 -0
  119. package/docs/api-testing/planning.md +57 -0
  120. package/docs/api-testing/running-tests.md +55 -0
  121. package/docs/assets/cloud-report.png +0 -0
  122. package/docs/assets/html-report.png +0 -0
  123. package/docs/assets/langfuse-trace.png +0 -0
  124. package/docs/assets/successful-explore-run.png +0 -0
  125. package/docs/basics/getting-started.md +140 -0
  126. package/docs/basics/prerequisites.md +63 -0
  127. package/docs/basics/providers.md +362 -0
  128. package/docs/basics/running.md +78 -0
  129. package/docs/contributing/ai-integration-tests.md +57 -0
  130. package/docs/contributing/contributing.md +90 -0
  131. package/docs/contributing/demo-videos.md +36 -0
  132. package/docs/contributing/npm-package.md +138 -0
  133. package/docs/contributing/observability.md +227 -0
  134. package/docs/contributing/regression-tests.md +103 -0
  135. package/docs/contributing/testing.md +95 -0
  136. package/docs/doc-collection/basics.md +128 -0
  137. package/docs/doc-collection/crawling.md +67 -0
  138. package/docs/doc-collection/interactive-mode.md +99 -0
  139. package/docs/index.json +86 -0
  140. package/docs/reference/commands.md +994 -0
  141. package/docs/reference/configuration.md +569 -0
  142. package/docs/reference/scripting.md +303 -0
  143. package/docs/superpowers/plans/2026-08-01-actor-boat.md +925 -0
  144. package/docs/superpowers/plans/2026-08-01-prima-boat.md +1120 -0
  145. package/docs/superpowers/reviews/2026-08-06-prima-vs-playwright-cli.md +268 -0
  146. package/docs/superpowers/specs/2026-08-01-actor-boat-design.md +204 -0
  147. package/docs/superpowers/specs/2026-08-01-prima-boat-design.md +242 -0
  148. package/docs/superpowers/specs/2026-08-03-global-config-design.md +138 -0
  149. package/docs/superpowers/specs/2026-08-07-prima-fixes-design.md +394 -0
  150. package/docs/web-testing/agents.md +158 -0
  151. package/docs/web-testing/automated-tests.md +134 -0
  152. package/docs/web-testing/basics.md +91 -0
  153. package/docs/web-testing/customization.md +131 -0
  154. package/docs/web-testing/hooks.md +238 -0
  155. package/docs/web-testing/page-interaction.md +84 -0
  156. package/docs/web-testing/planner.md +122 -0
  157. package/docs/web-testing/rerun.md +164 -0
  158. package/docs/web-testing/researcher.md +380 -0
  159. package/docs/workflow/agentic-usage.md +233 -0
  160. package/docs/workflow/application-spec.md +73 -0
  161. package/docs/workflow/ci.md +202 -0
  162. package/docs/workflow/knowledge.md +310 -0
  163. package/docs/workflow/planning-styles.md +67 -0
  164. package/docs/workflow/reporting.md +133 -0
  165. package/docs/workflow/test-plans.md +90 -0
  166. package/models.json +3 -0
  167. package/package.json +8 -3
  168. package/rules/navigator/verification-actions.md +20 -0
  169. package/src/action-result.ts +11 -0
  170. package/src/action.ts +47 -7
  171. package/src/ai/captain/mixin.ts +3 -3
  172. package/src/ai/captain/web-mode.ts +7 -4
  173. package/src/ai/captain.ts +3 -0
  174. package/src/ai/navigator.ts +26 -15
  175. package/src/ai/pilot.ts +53 -2
  176. package/src/ai/planner.ts +7 -0
  177. package/src/ai/provider.ts +2 -4
  178. package/src/ai/rerunner.ts +2 -1
  179. package/src/ai/researcher.ts +2 -2
  180. package/src/ai/rules.ts +17 -10
  181. package/src/ai/task-agent.ts +4 -2
  182. package/src/ai/tester.ts +32 -18
  183. package/src/ai/tools.ts +132 -36
  184. package/src/application-spec-contract.ts +10 -0
  185. package/src/application-spec.ts +87 -0
  186. package/src/browser-server.ts +74 -19
  187. package/src/commands/clean-command.ts +1 -6
  188. package/src/commands/init-command.ts +146 -1
  189. package/src/commands/navigate-command.ts +1 -1
  190. package/src/commands/research-command.ts +1 -1
  191. package/src/commands/sites-command.ts +27 -0
  192. package/src/components/InitWizard.tsx +166 -0
  193. package/src/components/InputReadline.tsx +8 -4
  194. package/src/config.ts +177 -43
  195. package/src/execution-controller.ts +8 -0
  196. package/src/explorbot.ts +34 -7
  197. package/src/explorer.ts +47 -9
  198. package/src/global-config.ts +148 -0
  199. package/src/knowledge-tracker.ts +17 -1
  200. package/src/playwright-recorder.ts +23 -0
  201. package/src/remote.ts +238 -0
  202. package/src/reporter.ts +7 -1
  203. package/src/state-manager.ts +2 -1
  204. package/src/stats.ts +1 -0
  205. package/src/test-plan.ts +9 -0
  206. package/src/utils/aria.ts +65 -45
  207. package/src/utils/cli-name.ts +5 -2
  208. package/src/utils/html.ts +1 -0
  209. package/src/utils/logger.ts +24 -1
  210. package/src/utils/page-readiness.ts +24 -1
  211. package/src/utils/test-files.ts +1 -2
  212. package/src/utils/url-matcher.ts +13 -0
  213. package/src/utils/web-element.ts +9 -0
  214. package/src/utils/web-sandbox.ts +3 -4
@@ -0,0 +1,148 @@
1
+ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
2
+ import os from 'node:os';
3
+ import { join } from 'node:path';
4
+
5
+ const GLOBAL_CONFIG_NAMES = ['config.js', 'config.mjs', 'config.ts'];
6
+ const SITE_DIRS = ['knowledge', 'experience', 'output'];
7
+
8
+ export function globalDir(): string {
9
+ return join(os.homedir(), '.explorbot');
10
+ }
11
+
12
+ export function globalEnvPath(): string {
13
+ return join(globalDir(), '.env');
14
+ }
15
+
16
+ export function globalConfigPath(): string {
17
+ return join(globalDir(), 'config.js');
18
+ }
19
+
20
+ export function findGlobalConfig(): string | null {
21
+ for (const name of GLOBAL_CONFIG_NAMES) {
22
+ const fullPath = join(globalDir(), name);
23
+ if (existsSync(fullPath)) return fullPath;
24
+ }
25
+ return null;
26
+ }
27
+
28
+ export function isGlobalConfigPath(configPath: string): boolean {
29
+ return GLOBAL_CONFIG_NAMES.some((name) => join(globalDir(), name) === configPath);
30
+ }
31
+
32
+ export function sitesDir(): string {
33
+ return join(globalDir(), 'sites');
34
+ }
35
+
36
+ export function siteFolderName(url: string): string {
37
+ return new URL(url).host.toLowerCase().replace(/[^a-z0-9._-]/g, '_');
38
+ }
39
+
40
+ export function listSites(): SiteRecord[] {
41
+ if (!existsSync(sitesDir())) return [];
42
+
43
+ return readdirSync(sitesDir(), { withFileTypes: true })
44
+ .filter((entry) => entry.isDirectory())
45
+ .map((entry) => readSite(entry.name))
46
+ .filter((site): site is SiteRecord => !!site)
47
+ .sort((a, b) => b.lastRunAt.localeCompare(a.lastRunAt));
48
+ }
49
+
50
+ export function registerSite(baseUrl: string): SiteRecord {
51
+ const folder = siteFolderName(baseUrl);
52
+ const dir = join(sitesDir(), folder);
53
+ for (const subDir of SITE_DIRS) {
54
+ mkdirSync(join(dir, subDir), { recursive: true, mode: 0o700 });
55
+ }
56
+
57
+ const now = new Date().toISOString();
58
+ const meta = {
59
+ url: baseUrl,
60
+ createdAt: readSite(folder)?.createdAt || now,
61
+ lastRunAt: now,
62
+ };
63
+ writeFileSync(join(dir, 'site.json'), `${JSON.stringify(meta, null, 2)}\n`, 'utf8');
64
+
65
+ return { folder, dir, ...meta };
66
+ }
67
+
68
+ export function resolveSiteTarget(target?: string, defaultBaseUrl?: string): SiteTarget {
69
+ const raw = (target || process.env.EXPLORBOT_URL || defaultBaseUrl || '').trim();
70
+ if (!raw) {
71
+ throw new Error(withSites('No site to explore. Pass a URL to the command or set EXPLORBOT_URL.'));
72
+ }
73
+
74
+ if (raw.startsWith('http://') || raw.startsWith('https://')) {
75
+ const url = new URL(raw);
76
+ return { baseUrl: url.origin, path: `${url.pathname}${url.search}${url.hash}` };
77
+ }
78
+
79
+ if (raw.startsWith('/')) {
80
+ const base = defaultBaseUrl || process.env.EXPLORBOT_URL;
81
+ if (!base) {
82
+ throw new Error(withSites(`Cannot resolve path "${raw}" without a site.`));
83
+ }
84
+ return { baseUrl: new URL(base).origin, path: raw };
85
+ }
86
+
87
+ let reference = raw;
88
+ let path = '/';
89
+ const separator = raw.indexOf('/');
90
+ if (separator > -1) {
91
+ reference = raw.slice(0, separator);
92
+ path = raw.slice(separator);
93
+ }
94
+
95
+ const site = findSite(reference);
96
+ if (!site) {
97
+ throw new Error(withSites(`Unknown site "${reference}".`));
98
+ }
99
+ return { baseUrl: site.url, path };
100
+ }
101
+
102
+ function readSite(folder: string): SiteRecord | null {
103
+ const dir = join(sitesDir(), folder);
104
+ const metaPath = join(dir, 'site.json');
105
+ if (!existsSync(metaPath)) return null;
106
+
107
+ try {
108
+ const meta = JSON.parse(readFileSync(metaPath, 'utf8'));
109
+ new URL(meta.url);
110
+ return { folder, dir, url: meta.url, createdAt: meta.createdAt, lastRunAt: meta.lastRunAt };
111
+ } catch {
112
+ return null;
113
+ }
114
+ }
115
+
116
+ function findSite(reference: string): SiteRecord | null {
117
+ const normalized = reference.toLowerCase().replace(/[^a-z0-9._-]/g, '_');
118
+ const sites = listSites();
119
+ const byFolder = sites.find((site) => site.folder === normalized);
120
+ if (byFolder) return byFolder;
121
+ return sites.find((site) => new URL(site.url).host.toLowerCase() === reference.toLowerCase()) || null;
122
+ }
123
+
124
+ function withSites(message: string): string {
125
+ const lines = [message];
126
+ const sites = listSites();
127
+ if (sites.length) {
128
+ lines.push('Registered sites:');
129
+ for (const site of sites) lines.push(` ${site.folder} → ${site.url}`);
130
+ }
131
+ lines.push('Explore a new site by passing its full URL, e.g. https://app.example.com/login');
132
+ return lines.join('\n');
133
+ }
134
+
135
+ interface SiteRecord {
136
+ folder: string;
137
+ dir: string;
138
+ url: string;
139
+ createdAt: string;
140
+ lastRunAt: string;
141
+ }
142
+
143
+ interface SiteTarget {
144
+ baseUrl: string;
145
+ path: string;
146
+ }
147
+
148
+ export type { SiteRecord, SiteTarget };
@@ -3,6 +3,7 @@ import { join } from 'node:path';
3
3
  import dedent from 'dedent';
4
4
  import matter from 'gray-matter';
5
5
  import { ActionResult } from './action-result.js';
6
+ import { ApplicationSpec } from './application-spec.ts';
6
7
  import { ConfigParser } from './config.js';
7
8
  import { getCliName } from './utils/cli-name.ts';
8
9
  import { createDebug, pluralize, tag } from './utils/logger.js';
@@ -24,8 +25,9 @@ export class KnowledgeTracker {
24
25
  private knowledgeDir: string;
25
26
  private knowledgeFiles: Knowledge[] = [];
26
27
  private isLoaded = false;
28
+ private applicationSpec?: ApplicationSpec;
27
29
 
28
- constructor() {
30
+ constructor(applicationSpecPath?: string) {
29
31
  const configParser = ConfigParser.getInstance();
30
32
  const config = configParser.getConfig();
31
33
  this.knowledgeDir = configParser.resolveProjectDir(config.dirs?.knowledge || 'knowledge');
@@ -33,6 +35,12 @@ export class KnowledgeTracker {
33
35
  if (!existsSync(this.knowledgeDir)) {
34
36
  mkdirSync(this.knowledgeDir, { recursive: true });
35
37
  }
38
+
39
+ const specPath = applicationSpecPath || config.dirs?.spec;
40
+ if (specPath) {
41
+ this.applicationSpec = new ApplicationSpec(specPath);
42
+ tag('info').log(`Loaded application spec with ${this.applicationSpec.pageCount} documented pages`);
43
+ }
36
44
  }
37
45
 
38
46
  private loadKnowledgeFiles(): void {
@@ -79,6 +87,14 @@ export class KnowledgeTracker {
79
87
  `;
80
88
  }
81
89
 
90
+ renderRelevantContext(state: ActionResult): string {
91
+ return [this.renderRelevantKnowledge(state), this.renderApplicationSpec(state)].filter(Boolean).join('\n\n');
92
+ }
93
+
94
+ renderApplicationSpec(state: ActionResult): string {
95
+ return this.applicationSpec?.renderFor(state) || '';
96
+ }
97
+
82
98
  addKnowledge(urlPattern: string, description: string): { filename: string; filePath: string; isNewFile: boolean } {
83
99
  const configParser = ConfigParser.getInstance();
84
100
  const configPath = configParser.getConfigPath();
@@ -287,8 +287,31 @@ function formatSelectOption(options: any): string {
287
287
  return `[${values.map((v) => quote(v)).join(', ')}]`;
288
288
  }
289
289
 
290
+ function assertionLocator(target: any): string | null {
291
+ if (typeof target === 'string') return `page.locator(${JSON.stringify(target)})`;
292
+ if (!target || typeof target !== 'object') return null;
293
+
294
+ const role = target.role || target.aria;
295
+ const name = target.text ?? target.name ?? target.label;
296
+ if (role && name) return `page.getByRole(${JSON.stringify(String(role))}, { name: ${JSON.stringify(String(name))} })`;
297
+ if (role) return `page.getByRole(${JSON.stringify(String(role))})`;
298
+ if (name) return `page.getByText(${JSON.stringify(String(name))})`;
299
+ if (target.css) return `page.locator(${JSON.stringify(String(target.css))})`;
300
+ if (target.xpath) return `page.locator(${JSON.stringify(`xpath=${target.xpath}`)})`;
301
+ return null;
302
+ }
303
+
290
304
  export function renderAssertion(assertion: { name: string; args: any[] }): string {
291
305
  const args = assertion.args;
306
+ const target = assertionLocator(args[0]);
307
+
308
+ if (target) {
309
+ if (assertion.name === 'seeElement') return `await expect(${target}).toBeVisible();`;
310
+ if (assertion.name === 'dontSeeElement') return `await expect(${target}).toBeHidden();`;
311
+ if (assertion.name === 'seeInField' && args[1] !== undefined) return `await expect(${target}).toHaveValue(${JSON.stringify(String(args[1]))});`;
312
+ if (assertion.name === 'dontSeeInField' && args[1] !== undefined) return `await expect(${target}).not.toHaveValue(${JSON.stringify(String(args[1]))});`;
313
+ }
314
+
292
315
  if (assertion.name === 'see' && typeof args[0] === 'string') {
293
316
  return `await expect(page).toContainText(${JSON.stringify(args[0])});`;
294
317
  }
package/src/remote.ts ADDED
@@ -0,0 +1,238 @@
1
+ import type { Command } from 'commander';
2
+ import stripAnsi from 'strip-ansi';
3
+ import { type ActivityEntry, addActivityListener } from './activity.ts';
4
+ import { executionController } from './execution-controller.ts';
5
+ import { type LogDestination, type TaggedLogEntry, addDestination } from './utils/logger.ts';
6
+
7
+ const QUEUE_CAP = 1000;
8
+ const CONTENT_CAP = 8000;
9
+ const RECONNECT_BASE_MS = 500;
10
+ const RECONNECT_MAX_MS = 10_000;
11
+ const ASK_TIMEOUT_MS = 15 * 60_000;
12
+ const FLUSH_TIMEOUT_MS = 3000;
13
+
14
+ /**
15
+ * Streams a run to a remote UI over one WebSocket, dialling out so a child
16
+ * process and a CI bot are the same case.
17
+ *
18
+ * It **is** a LogDestination — that is the whole integration on the logger's
19
+ * side — and it answers asks by installing itself as the execution
20
+ * controller's input callback. Nothing else in explorbot knows it exists.
21
+ */
22
+ export class Remote implements LogDestination {
23
+ private url: string | null = null;
24
+ private socket: WebSocket | null = null;
25
+ private queue: Frame[] = [];
26
+ private reconnectDelay = RECONNECT_BASE_MS;
27
+ private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
28
+ private asks = new Map<string, (value: string | null) => void>();
29
+ private askCounter = 0;
30
+ private lastActivity: string | null = null;
31
+
32
+ registerOption(program: Command): void {
33
+ program.option('--ws <url>', 'Stream this run to a remote UI over WebSocket');
34
+ program.hook('preAction', (_thisCommand, actionCommand) => {
35
+ const url = actionCommand.optsWithGlobals().ws || process.env.EXPLORBOT_WS_URL;
36
+ if (!url) return;
37
+ this.attach(String(url), this.commandPath(actionCommand));
38
+ });
39
+ }
40
+
41
+ attach(url: string, command: string): void {
42
+ if (this.url) return;
43
+ this.url = url;
44
+ this.connect();
45
+
46
+ this.send('hello', { command, cwd: process.cwd(), pid: process.pid });
47
+ addDestination(this);
48
+ executionController.setInputCallback((prompt) => this.ask(prompt));
49
+ addActivityListener((activity) => this.reportActivity(activity));
50
+ }
51
+
52
+ isAttached(): boolean {
53
+ return !!this.url;
54
+ }
55
+
56
+ send(type: string, data: Record<string, unknown> = {}): void {
57
+ if (!this.url) return;
58
+ const frame: Frame = { type, ts: Date.now(), ...data };
59
+ if (this.socket?.readyState === WebSocket.OPEN) {
60
+ this.push(frame);
61
+ return;
62
+ }
63
+ this.queue.push(frame);
64
+ if (this.queue.length > QUEUE_CAP) this.queue.splice(0, this.queue.length - QUEUE_CAP);
65
+ }
66
+
67
+ ask(prompt: string): Promise<string | null> {
68
+ if (!this.url) return Promise.resolve(null);
69
+ this.askCounter++;
70
+ const askId = `ask-${this.askCounter}`;
71
+ this.send('ask', { askId, prompt });
72
+
73
+ return new Promise((resolve) => {
74
+ const timer = setTimeout(() => {
75
+ this.asks.delete(askId);
76
+ resolve(null);
77
+ }, ASK_TIMEOUT_MS);
78
+ this.asks.set(askId, (value) => {
79
+ clearTimeout(timer);
80
+ resolve(value);
81
+ });
82
+ });
83
+ }
84
+
85
+ async close(exitCode: number): Promise<void> {
86
+ if (!this.url) return;
87
+ this.send('result', { ok: exitCode === 0, exitCode });
88
+ await this.flush();
89
+
90
+ this.url = null;
91
+ if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
92
+ // Whoever asks next has nobody to ask — leaving the callback installed would
93
+ // route them into a closed socket and park them until the ask times out.
94
+ executionController.clearInputCallback();
95
+ for (const resolve of this.asks.values()) resolve(null);
96
+ this.asks.clear();
97
+ this.queue = [];
98
+ this.socket?.close();
99
+ this.socket = null;
100
+ }
101
+
102
+ isEnabled(): boolean {
103
+ return this.isAttached();
104
+ }
105
+
106
+ /**
107
+ * The entry as the logger already built it, so the `LogType` reaches the UI
108
+ * as the frame's level and each kind is styled there instead of being scraped
109
+ * back out of flattened text.
110
+ */
111
+ write(entry: TaggedLogEntry): void {
112
+ if (entry.type === 'html') return;
113
+ let content = stripAnsi(entry.content ?? '');
114
+ if (content.length > CONTENT_CAP) content = `${content.slice(0, CONTENT_CAP)}… (${content.length} chars)`;
115
+ this.send('log', {
116
+ level: entry.type,
117
+ content,
118
+ namespace: entry.namespace,
119
+ error: this.errorOf(entry.originalArgs),
120
+ });
121
+ }
122
+
123
+ private connect(): void {
124
+ if (!this.url) return;
125
+
126
+ let opening: WebSocket;
127
+ try {
128
+ opening = new WebSocket(this.url);
129
+ } catch {
130
+ this.reconnect();
131
+ return;
132
+ }
133
+ this.socket = opening;
134
+
135
+ opening.addEventListener('open', () => {
136
+ this.reconnectDelay = RECONNECT_BASE_MS;
137
+ this.drain();
138
+ });
139
+
140
+ opening.addEventListener('message', (event: MessageEvent) => this.receive(String(event.data)));
141
+
142
+ opening.addEventListener('close', () => {
143
+ if (this.socket !== opening) return;
144
+ this.socket = null;
145
+ this.reconnect();
146
+ });
147
+
148
+ // A failed connection always closes too, which is where the retry is scheduled.
149
+ opening.addEventListener('error', () => {});
150
+ }
151
+
152
+ private reconnect(): void {
153
+ if (!this.url || this.reconnectTimer) return;
154
+ const delay = this.reconnectDelay;
155
+ this.reconnectDelay = Math.min(this.reconnectDelay * 2, RECONNECT_MAX_MS);
156
+ this.reconnectTimer = setTimeout(() => {
157
+ this.reconnectTimer = null;
158
+ this.connect();
159
+ }, delay);
160
+ this.reconnectTimer.unref?.();
161
+ }
162
+
163
+ private async flush(): Promise<void> {
164
+ const deadline = Date.now() + FLUSH_TIMEOUT_MS;
165
+ while (Date.now() < deadline) {
166
+ if (this.socket?.readyState === WebSocket.OPEN) {
167
+ this.drain();
168
+ if (!this.queue.length && !this.socket.bufferedAmount) return;
169
+ }
170
+ await new Promise((resolve) => setTimeout(resolve, 25));
171
+ }
172
+ }
173
+
174
+ private drain(): void {
175
+ if (this.socket?.readyState !== WebSocket.OPEN) return;
176
+ const pending = this.queue;
177
+ this.queue = [];
178
+ for (const frame of pending) this.push(frame);
179
+ }
180
+
181
+ private push(frame: Frame): void {
182
+ try {
183
+ this.socket?.send(JSON.stringify(frame));
184
+ } catch {
185
+ this.queue.push(frame);
186
+ }
187
+ }
188
+
189
+ private receive(raw: string): void {
190
+ let frame: Frame;
191
+ try {
192
+ frame = JSON.parse(raw);
193
+ } catch {
194
+ return;
195
+ }
196
+ if (frame.type === 'interrupt') {
197
+ executionController.interrupt();
198
+ return;
199
+ }
200
+ const resolve = this.asks.get(String(frame.askId));
201
+ if (!resolve) return;
202
+ this.asks.delete(String(frame.askId));
203
+ resolve((frame.value as string | null) ?? null);
204
+ }
205
+
206
+ /** A new listener is primed with the current activity, which at attach time is
207
+ * nothing — so skip repeats, the priming null included. */
208
+ private reportActivity(activity: ActivityEntry | null): void {
209
+ const message = activity?.message ?? null;
210
+ if (message === this.lastActivity) return;
211
+ this.lastActivity = message;
212
+ this.send('activity', { message, kind: activity?.type });
213
+ }
214
+
215
+ private errorOf(args: any[] | undefined): string | undefined {
216
+ const failure = args?.[1] ?? args?.[0]?.error;
217
+ if (!failure) return undefined;
218
+ if (typeof failure === 'string') return failure;
219
+ if (typeof failure.message === 'string') return failure.message;
220
+ return undefined;
221
+ }
222
+
223
+ private commandPath(command: Command): string {
224
+ const parts: string[] = [];
225
+ let node: Command | null = command;
226
+ while (node) {
227
+ parts.unshift(node.name());
228
+ node = node.parent;
229
+ }
230
+ return parts.slice(1).join(' ') || parts.join(' ');
231
+ }
232
+ }
233
+
234
+ export const remote = new Remote();
235
+
236
+ /** Whatever the run wants to say. Not a schema — the UI renders what it knows
237
+ * and ignores the rest, so either side can start sending more at any time. */
238
+ type Frame = { type: string; ts: number; [key: string]: unknown };
package/src/reporter.ts CHANGED
@@ -283,13 +283,19 @@ export class Reporter {
283
283
 
284
284
  debugLog(testData);
285
285
 
286
- await this.client.addTestRun(status, testData);
286
+ await this.reportTestData(status, testData);
287
287
  debugLog(`Test reported: ${test.scenario} - ${status}`);
288
288
  } catch (error) {
289
289
  debugLog('Failed to report test:', error);
290
290
  }
291
291
  }
292
292
 
293
+ async reportTestData(status: string | null, testData: Record<string, unknown>): Promise<void> {
294
+ await this.startRun();
295
+ if (!this.isRunStarted) return;
296
+ await this.client.addTestRun(status, testData);
297
+ }
298
+
293
299
  async finishRun(): Promise<void> {
294
300
  if (!this.isRunStarted) {
295
301
  return;
@@ -1,4 +1,4 @@
1
- import { ActionResult } from './action-result.js';
1
+ import { type FocusedElement, ActionResult } from './action-result.js';
2
2
  import type { ExperienceTracker } from './experience-tracker.js';
3
3
  import type { Knowledge, KnowledgeTracker } from './knowledge-tracker.js';
4
4
  import { detectFocusArea } from './utils/aria.js';
@@ -46,6 +46,7 @@ export interface WebPageState {
46
46
  h4?: string;
47
47
  ariaSnapshot?: string | null;
48
48
  ariaSnapshotFile?: string;
49
+ focusedElement?: FocusedElement | null;
49
50
  links?: Link[];
50
51
  verifications?: Record<string, boolean>;
51
52
  }
package/src/stats.ts CHANGED
@@ -17,6 +17,7 @@ export class Stats {
17
17
  static plans = 0;
18
18
  static mode?: ExplorbotMode;
19
19
  static focus?: string;
20
+ static visionDisabled = false;
20
21
  static models: Record<string, TokenUsage> = {};
21
22
 
22
23
  static recordTokens(_agent: string, model: string, usage: TokenUsage): void {
package/src/test-plan.ts CHANGED
@@ -27,6 +27,7 @@ export interface Note {
27
27
  endTime: number;
28
28
  screenshot?: string;
29
29
  log?: string;
30
+ observation?: boolean;
30
31
  }
31
32
 
32
33
  export class ActiveNote {
@@ -131,6 +132,14 @@ export class Task {
131
132
  this.notes[timestamp] = { message, status, startTime: now, endTime: now, screenshot, log };
132
133
  }
133
134
 
135
+ addObservation(message: string): void {
136
+ const isDuplicate = Object.values(this.notes).some((note) => note.message === message);
137
+ if (isDuplicate) return;
138
+
139
+ const now = performance.now();
140
+ this.notes[`${now}_${this.timestampCounter++}`] = { message, status: TestResult.FAILED, startTime: now, endTime: now, observation: true };
141
+ }
142
+
134
143
  addUrlNote(state: UrlNoteState, prevState?: { title?: string; h1?: string; h2?: string }): void {
135
144
  const fullUrl = state.fullUrl || state.url;
136
145
  if (!fullUrl) return;