explorbot 0.1.27 → 0.1.28

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 (48) hide show
  1. package/dist/package.json +1 -1
  2. package/dist/src/action-result.js +2 -0
  3. package/dist/src/action.js +88 -7
  4. package/dist/src/ai/captain/file-tools.js +100 -0
  5. package/dist/src/ai/captain/idle-mode.js +70 -6
  6. package/dist/src/ai/captain/web-mode.js +36 -6
  7. package/dist/src/ai/captain.js +87 -19
  8. package/dist/src/ai/historian/screencast.js +11 -2
  9. package/dist/src/ai/navigator.js +5 -2
  10. package/dist/src/ai/pilot.js +33 -5
  11. package/dist/src/ai/researcher/coordinates.js +2 -3
  12. package/dist/src/ai/researcher/deep-analysis.js +3 -4
  13. package/dist/src/ai/researcher/locators.js +1 -2
  14. package/dist/src/ai/researcher.js +17 -18
  15. package/dist/src/ai/task-agent.js +1 -0
  16. package/dist/src/ai/tester.js +91 -39
  17. package/dist/src/ai/tools.js +17 -7
  18. package/dist/src/commands/explore-command.js +6 -1
  19. package/dist/src/components/LogPane.js +4 -3
  20. package/dist/src/explorer.js +270 -35
  21. package/dist/src/utils/browser-errors.js +23 -0
  22. package/dist/src/utils/error-page.js +17 -2
  23. package/dist/src/utils/logger.js +2 -2
  24. package/package.json +1 -1
  25. package/src/action-result.ts +2 -0
  26. package/src/action.ts +83 -7
  27. package/src/ai/captain/file-tools.ts +126 -0
  28. package/src/ai/captain/idle-mode.ts +72 -6
  29. package/src/ai/captain/mixin.ts +1 -1
  30. package/src/ai/captain/web-mode.ts +40 -5
  31. package/src/ai/captain.ts +94 -20
  32. package/src/ai/historian/screencast.ts +11 -2
  33. package/src/ai/navigator.ts +6 -2
  34. package/src/ai/pilot.ts +34 -5
  35. package/src/ai/researcher/coordinates.ts +2 -3
  36. package/src/ai/researcher/deep-analysis.ts +3 -4
  37. package/src/ai/researcher/locators.ts +1 -2
  38. package/src/ai/researcher.ts +17 -18
  39. package/src/ai/task-agent.ts +1 -1
  40. package/src/ai/tester.ts +101 -41
  41. package/src/ai/tools.ts +17 -7
  42. package/src/commands/explore-command.ts +6 -1
  43. package/src/components/LogPane.tsx +4 -3
  44. package/src/explorer.ts +295 -38
  45. package/src/state-manager.ts +2 -0
  46. package/src/utils/browser-errors.ts +25 -0
  47. package/src/utils/error-page.ts +16 -3
  48. package/src/utils/logger.ts +3 -3
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "explorbot",
3
- "version": "0.1.27",
3
+ "version": "0.1.28",
4
4
  "description": "CLI app built with React Ink, CodeceptJS, and Playwright",
5
5
  "license": "Elastic-2.0",
6
6
  "type": "module",
@@ -10,6 +10,7 @@ const debugLog = createDebug('explorbot:state');
10
10
  export class ActionResult {
11
11
  id;
12
12
  title = '';
13
+ httpStatus = undefined;
13
14
  error = null;
14
15
  timestamp = new Date();
15
16
  h1 = undefined;
@@ -39,6 +40,7 @@ export class ActionResult {
39
40
  this.url = data.url ?? '';
40
41
  this.fullUrl = data.fullUrl;
41
42
  this.title = data.title ?? '';
43
+ this.httpStatus = data.httpStatus;
42
44
  this.error = data.error ?? null;
43
45
  this.browserLogs = data.browserLogs ?? [];
44
46
  this.iframeSnapshots = data.iframeSnapshots ?? [];
@@ -13,8 +13,8 @@ import { Observability } from "./observability.js";
13
13
  import { htmlCombinedSnapshot, minifyHtml } from './utils/html.js';
14
14
  import { createDebug, setStepSpanParent, tag } from './utils/logger.js';
15
15
  import { safeFilename } from "./utils/strings.js";
16
+ import { isFatalBrowserError } from "./utils/browser-errors.js";
16
17
  const debugLog = createDebug('explorbot:action');
17
- const FATAL_BROWSER_ERRORS = /Frame was detached|Target closed|Execution context was destroyed|Protocol error|Session closed/i;
18
18
  class Action {
19
19
  actor;
20
20
  stateManager;
@@ -28,6 +28,7 @@ class Action {
28
28
  playwrightGroupId = null;
29
29
  assertionSteps = [];
30
30
  recorder;
31
+ mainDocumentStatus = undefined;
31
32
  constructor(actor, stateManager, recorder) {
32
33
  this.actor = actor;
33
34
  this.stateManager = stateManager;
@@ -63,20 +64,25 @@ class Action {
63
64
  const page = this.playwrightHelper.page;
64
65
  const frame = this.playwrightHelper.frame;
65
66
  await page?.waitForLoadState('domcontentloaded', { timeout: 10000 })?.catch(() => { });
66
- const grabAll = () => Promise.all([this.actor.grabSource(), this.actor.grabTitle(), this.captureBrowserLogs()]);
67
+ await waitForUsablePageDom(page);
68
+ const grabAll = () => Promise.all([captureHtml(page, frame, this.actor), captureTitle(page, this.actor), this.captureBrowserLogs()]);
67
69
  const [html, title, browserLogs] = await grabAll().catch(async (err) => {
68
70
  const msg = err instanceof Error ? err.message : String(err);
69
71
  if (!/navigating and changing the content/i.test(msg))
70
72
  throw err;
71
73
  await page?.waitForLoadState('domcontentloaded', { timeout: 10000 })?.catch(() => { });
74
+ await waitForUsablePageDom(page);
72
75
  return grabAll();
73
76
  });
74
77
  const url = page?.url() || (await this.actor.grabCurrentUrl?.());
75
78
  let screenshotFile = undefined;
79
+ const statesDir = outputPath('states');
80
+ fs.mkdirSync(statesDir, { recursive: true });
76
81
  if (includeScreenshot) {
77
82
  const filename = safeFilename(`${stateHash}_${timestamp}`, '.png');
78
- screenshotFile = await this.actor
79
- .saveScreenshot(filename)
83
+ const screenshotPath = join(statesDir, filename);
84
+ screenshotFile = await page
85
+ ?.screenshot({ path: screenshotPath, fullPage: true })
80
86
  .then(() => filename)
81
87
  .catch((err) => {
82
88
  debugLog('Screenshot failed, continuing without it:', err);
@@ -84,8 +90,6 @@ class Action {
84
90
  });
85
91
  }
86
92
  // Save HTML to file
87
- const statesDir = outputPath('states');
88
- fs.mkdirSync(statesDir, { recursive: true });
89
93
  const htmlFile = safeFilename(`${stateHash}_${timestamp}`, '.html');
90
94
  const htmlPath = join(statesDir, htmlFile);
91
95
  fs.writeFileSync(htmlPath, html, 'utf8');
@@ -121,6 +125,7 @@ class Action {
121
125
  const result = new ActionResult({
122
126
  html,
123
127
  title,
128
+ httpStatus: await this.captureMainDocumentStatus(),
124
129
  url,
125
130
  browserLogs,
126
131
  htmlFile,
@@ -136,13 +141,57 @@ class Action {
136
141
  }
137
142
  catch (err) {
138
143
  const msg = err instanceof Error ? err.message : String(err);
139
- if (FATAL_BROWSER_ERRORS.test(msg))
144
+ if (isFatalBrowserError(err))
140
145
  throw err;
141
146
  debugLog('capturePageState failed with non-fatal error:', msg);
142
147
  const url = this.playwrightHelper.page?.url?.() || '';
143
148
  return new ActionResult({ url, error: msg });
144
149
  }
145
150
  }
151
+ async captureMainDocumentStatus() {
152
+ if (this.mainDocumentStatus)
153
+ return this.mainDocumentStatus;
154
+ try {
155
+ const page = this.playwrightHelper.page;
156
+ const status = await page.evaluate(() => {
157
+ const navigation = performance.getEntriesByType('navigation').at(-1);
158
+ if (!navigation)
159
+ return undefined;
160
+ if (new URL(navigation.name).href !== window.location.href)
161
+ return undefined;
162
+ return navigation.responseStatus;
163
+ });
164
+ if (typeof status !== 'number')
165
+ return undefined;
166
+ if (status <= 0)
167
+ return undefined;
168
+ return status;
169
+ }
170
+ catch {
171
+ return undefined;
172
+ }
173
+ }
174
+ captureMainDocumentResponse() {
175
+ const page = this.playwrightHelper.page;
176
+ if (!page?.on || !page?.off)
177
+ return () => { };
178
+ this.mainDocumentStatus = undefined;
179
+ const handler = (response) => {
180
+ const request = response.request();
181
+ if (request.resourceType() !== 'document')
182
+ return;
183
+ if (response.frame() !== page.mainFrame())
184
+ return;
185
+ const status = response.status();
186
+ if (typeof status !== 'number')
187
+ return;
188
+ if (status <= 0)
189
+ return;
190
+ this.mainDocumentStatus = status;
191
+ };
192
+ page.on('response', handler);
193
+ return () => page.off('response', handler);
194
+ }
146
195
  /**
147
196
  * Capture HTML snapshots of all iframes on the page
148
197
  */
@@ -200,6 +249,7 @@ class Action {
200
249
  const stepListener = attachStepLogger(executedSteps, assertionSteps);
201
250
  const groupId = this.recorder ? await this.recorder.beginAction(codeString) : null;
202
251
  this.playwrightGroupId = groupId;
252
+ const detachMainDocumentResponse = this.captureMainDocumentResponse();
203
253
  const activeSpan = Observability.getSpan();
204
254
  const tracer = trace.getTracer('ai');
205
255
  const stepSpan = activeSpan ? tracer.startSpan('codeceptjs.step', undefined, trace.setSpan(context.active(), activeSpan)) : null;
@@ -242,6 +292,7 @@ class Action {
242
292
  throw err;
243
293
  }
244
294
  finally {
295
+ detachMainDocumentResponse();
245
296
  if (groupId)
246
297
  await this.recorder.endAction();
247
298
  detachStepLogger(stepListener);
@@ -330,6 +381,8 @@ class Action {
330
381
  }
331
382
  catch (error) {
332
383
  this.lastError = error;
384
+ if (isFatalBrowserError(error))
385
+ throw error;
333
386
  debugLog(`Attempt failed: ${codeBlock}: ${errorToString(error) || this.lastError?.toString()}`);
334
387
  return false;
335
388
  }
@@ -354,6 +407,34 @@ function errorToString(error) {
354
407
  }
355
408
  return error.message || error.toString();
356
409
  }
410
+ async function waitForUsablePageDom(page) {
411
+ if (!page?.waitForFunction)
412
+ return;
413
+ await page
414
+ .waitForFunction(() => {
415
+ const body = document.body;
416
+ if (!body)
417
+ return false;
418
+ return body.children.length > 0 || body.textContent?.trim().length > 0;
419
+ }, undefined, { timeout: 5000 })
420
+ .catch(() => { });
421
+ }
422
+ async function captureHtml(page, frame, actor) {
423
+ if (frame?.content)
424
+ return frame.content();
425
+ if (page?.content)
426
+ return page.content();
427
+ if (actor?.grabSource)
428
+ return actor.grabSource();
429
+ throw new Error('Playwright page is unavailable for HTML capture');
430
+ }
431
+ async function captureTitle(page, actor) {
432
+ if (page?.title)
433
+ return page.title();
434
+ if (actor?.grabTitle)
435
+ return actor.grabTitle();
436
+ return '';
437
+ }
357
438
  function sanitizeCodeBlock(code) {
358
439
  return code
359
440
  .split('\n')
@@ -0,0 +1,100 @@
1
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
2
+ import { basename, isAbsolute, join, relative, resolve } from 'node:path';
3
+ export const CAPTAIN_ARTIFACT_DIRS = ['reports', 'plans', 'tests', 'states'];
4
+ export const CAPTAIN_ALLOWED_READ_DIRS = ['output', 'knowledge', 'experience'];
5
+ export const CAPTAIN_ARTIFACT_SCAN_LIMIT = 200;
6
+ export const CAPTAIN_ARTIFACT_LIST_LIMIT = 20;
7
+ export const CAPTAIN_READ_FILE_DEFAULT_LIMIT = 12000;
8
+ export const CAPTAIN_READ_FILE_MAX_LIMIT = 50000;
9
+ export const CAPTAIN_READ_FILE_MIN_LIMIT = 1000;
10
+ export function listRecentArtifacts(outputDir) {
11
+ const artifacts = [];
12
+ for (const dir of CAPTAIN_ARTIFACT_DIRS) {
13
+ if (artifacts.length >= CAPTAIN_ARTIFACT_SCAN_LIMIT)
14
+ break;
15
+ const targetDir = join(outputDir, dir);
16
+ if (!existsSync(targetDir))
17
+ continue;
18
+ collectArtifacts(outputDir, targetDir, artifacts);
19
+ }
20
+ return artifacts
21
+ .sort((a, b) => b.timestamp - a.timestamp)
22
+ .slice(0, CAPTAIN_ARTIFACT_LIST_LIMIT)
23
+ .map(({ timestamp, ...artifact }) => artifact);
24
+ }
25
+ export function readCaptainFile(projectRoot, input, allowedDirs = CAPTAIN_ALLOWED_READ_DIRS) {
26
+ const resolved = resolveReadableFile(projectRoot, input.path, allowedDirs);
27
+ if (!resolved) {
28
+ return { success: false, message: 'File is outside allowed directories' };
29
+ }
30
+ if (!existsSync(resolved)) {
31
+ return { success: false, message: `File not found: ${input.path}` };
32
+ }
33
+ if (!statSync(resolved).isFile()) {
34
+ return { success: false, message: `Not a file: ${input.path}` };
35
+ }
36
+ const maxChars = normalizeMaxChars(input.maxChars);
37
+ const fullContent = readFileSync(resolved, 'utf8');
38
+ const content = selectContent(fullContent, input);
39
+ return {
40
+ success: true,
41
+ path: relative(projectRoot || process.cwd(), resolved),
42
+ truncated: content.length > maxChars,
43
+ content: content.slice(0, maxChars),
44
+ };
45
+ }
46
+ function collectArtifacts(outputDir, targetDir, artifacts) {
47
+ for (const entry of readdirSync(targetDir, { withFileTypes: true })) {
48
+ if (artifacts.length >= CAPTAIN_ARTIFACT_SCAN_LIMIT)
49
+ return;
50
+ const entryPath = join(targetDir, entry.name);
51
+ if (entry.isDirectory()) {
52
+ collectArtifacts(outputDir, entryPath, artifacts);
53
+ continue;
54
+ }
55
+ const stats = statSync(entryPath);
56
+ artifacts.push({
57
+ path: relative(outputDir, entryPath),
58
+ size: stats.size,
59
+ modifiedAt: stats.mtime.toISOString(),
60
+ timestamp: stats.mtimeMs,
61
+ });
62
+ }
63
+ }
64
+ function resolveReadableFile(projectRoot, requestedPath, allowedDirs) {
65
+ if (!projectRoot)
66
+ return null;
67
+ let cleanPath = requestedPath.trim();
68
+ const projectName = basename(projectRoot);
69
+ if (cleanPath.startsWith(`${projectName}/`) || cleanPath.startsWith(`${projectName}\\`)) {
70
+ cleanPath = cleanPath.slice(projectName.length + 1);
71
+ }
72
+ const resolved = isAbsolute(cleanPath) ? resolve(cleanPath) : resolve(projectRoot, cleanPath);
73
+ const allowedRoots = allowedDirs.map((dir) => resolve(projectRoot, dir));
74
+ for (const root of allowedRoots) {
75
+ const rel = relative(root, resolved);
76
+ if (!rel || (!rel.startsWith('..') && !isAbsolute(rel)))
77
+ return resolved;
78
+ }
79
+ return null;
80
+ }
81
+ function selectContent(content, input) {
82
+ if (!input.startLine && !input.endLine)
83
+ return content;
84
+ const lines = content.split(/\r?\n/);
85
+ const startIndex = resolveLineIndex(input.startLine, lines.length, 1);
86
+ const endIndex = resolveLineIndex(input.endLine, lines.length, lines.length);
87
+ if (endIndex < startIndex)
88
+ return '';
89
+ return lines.slice(startIndex - 1, endIndex).join('\n');
90
+ }
91
+ function resolveLineIndex(line, totalLines, fallback) {
92
+ if (!line)
93
+ return fallback;
94
+ if (line < 0)
95
+ return Math.max(1, totalLines + line + 1);
96
+ return Math.min(Math.max(1, line), totalLines);
97
+ }
98
+ function normalizeMaxChars(maxChars) {
99
+ return Math.max(CAPTAIN_READ_FILE_MIN_LIMIT, Math.min(maxChars || CAPTAIN_READ_FILE_DEFAULT_LIMIT, CAPTAIN_READ_FILE_MAX_LIMIT));
100
+ }
@@ -4,6 +4,7 @@ import dedent from 'dedent';
4
4
  import { z } from 'zod';
5
5
  import { ConfigParser } from "../../config.js";
6
6
  import { Test } from "../../test-plan.js";
7
+ import { listRecentArtifacts, readCaptainFile } from "./file-tools.js";
7
8
  import { resolveProjectRoot } from "./mixin.js";
8
9
  let cachedBashTool = null;
9
10
  export function WithIdleMode(Base) {
@@ -13,6 +14,8 @@ export function WithIdleMode(Base) {
13
14
  const config = ConfigParser.getInstance().getConfig();
14
15
  const knowledgeDir = config.dirs?.knowledge || 'knowledge';
15
16
  const experienceDir = config.dirs?.experience || 'experience';
17
+ const outputDir = config.dirs?.output || 'output';
18
+ const readableDirs = [outputDir, knowledgeDir, experienceDir];
16
19
  if (!cachedBashTool && projectRoot) {
17
20
  cachedBashTool = await createBashTool({
18
21
  destination: projectRoot,
@@ -69,6 +72,53 @@ export function WithIdleMode(Base) {
69
72
  return { success: true, tests: plan.tests.length };
70
73
  },
71
74
  }),
75
+ project: tool({
76
+ description: dedent `
77
+ Inspect Explorbot project configuration and recent generated artifacts.
78
+ Use this before answering questions about setup, previous sessions, reports, saved plans, or output files.
79
+ `,
80
+ inputSchema: z.object({
81
+ view: z.enum(['config', 'artifacts']).optional().describe('config shows setup summary; artifacts lists recent generated files'),
82
+ }),
83
+ execute: async ({ view }) => {
84
+ const parser = ConfigParser.getInstance();
85
+ const config = parser.getConfig();
86
+ const outputDir = parser.getOutputDir();
87
+ if (view === 'artifacts') {
88
+ return {
89
+ success: true,
90
+ outputDir,
91
+ artifacts: listRecentArtifacts(outputDir),
92
+ suggestion: 'Use readFile to inspect specific reports, plans, logs, generated tests, knowledge, or experience files.',
93
+ };
94
+ }
95
+ return {
96
+ success: true,
97
+ configPath: parser.getConfigPath(),
98
+ baseUrl: config.playwright?.url,
99
+ browser: config.playwright?.browser,
100
+ headed: config.playwright?.show === true,
101
+ dirs: config.dirs,
102
+ agents: Object.fromEntries(Object.entries(config.ai?.agents || {}).map(([name, agentConfig]) => [name, { enabled: agentConfig?.enabled !== false, hasModelOverride: !!agentConfig?.model }])),
103
+ reporterEnabled: config.reporter?.enabled === true,
104
+ apiEnabled: !!config.api,
105
+ };
106
+ },
107
+ }),
108
+ readFile: tool({
109
+ description: dedent `
110
+ Read a specific Explorbot project file for analysis.
111
+ Use this for explicit user questions about reports, plans, logs, generated tests, knowledge, or experience files.
112
+ Prefer this over bash() for reading file contents after bash has found the file.
113
+ `,
114
+ inputSchema: z.object({
115
+ path: z.string().describe('Path inside output, knowledge, or experience directories'),
116
+ startLine: z.number().optional().describe('First line to read, 1-based. Negative values count from the end of the file'),
117
+ endLine: z.number().optional().describe('Last line to read, 1-based and inclusive. Negative values count from the end of the file'),
118
+ maxChars: z.number().optional().describe('Maximum characters to return, default 12000'),
119
+ }),
120
+ execute: async (input) => readCaptainFile(projectRoot, input, readableDirs),
121
+ }),
72
122
  };
73
123
  if (cachedBashTool) {
74
124
  tools.bash = cachedBashTool.bash;
@@ -79,17 +129,31 @@ export function WithIdleMode(Base) {
79
129
  const config = ConfigParser.getInstance().getConfig();
80
130
  const knowledgeDir = config.dirs?.knowledge || 'knowledge';
81
131
  const experienceDir = config.dirs?.experience || 'experience';
132
+ const outputDir = config.dirs?.output || 'output';
82
133
  return dedent `
83
134
  <idle_capabilities>
84
135
  - Plan management: updatePlan() — replace or append tests in the current plan
85
- - bash() — run shell commands for file operations
86
- - READ from: ${knowledgeDir}/, ${experienceDir}/, output/
87
- - WRITE to: ${knowledgeDir}/, ${experienceDir}/ only (NOT output/)
88
- - Use ls to list files, cat to read small files
89
- - Use head/tail for large files to avoid excessive output
90
- - Use grep to search file contents
136
+ - readFile() — read specific report, plan, log, generated test, knowledge, or experience file content
137
+ - bash() discover files and inspect file metadata
138
+ - READ from: ${knowledgeDir}/, ${experienceDir}/, ${outputDir}/
139
+ - WRITE to: ${knowledgeDir}/, ${experienceDir}/ only (NOT ${outputDir}/)
140
+ - Use wc -l -c file.txt to inspect size
141
+ - Use file file.txt to inspect type
142
+ - Use find . -name "*.md" to discover files
143
+ - Use grep -n "keyword" file.txt to find matching lines
144
+ - Use ls -lh to list files
91
145
  </idle_capabilities>
92
146
 
147
+ <file_reading>
148
+ Use bash() for file discovery and search. Once the needed file and line range are known,
149
+ use readFile() to read its contents. Do not use bash() to print file contents.
150
+ </file_reading>
151
+
152
+ <project_inspection>
153
+ Use project({ view: "config" }) before explaining Explorbot setup or suggesting config improvements.
154
+ Use project({ view: "artifacts" }) before answering questions about previous sessions, reports, plans, generated tests, or logs.
155
+ </project_inspection>
156
+
93
157
  <knowledge_saving>
94
158
  When user shares credentials, selectors, or important domain info during conversation,
95
159
  suggest saving it to a knowledge file using bash tool.
@@ -45,19 +45,49 @@ export function WithWebMode(Base) {
45
45
  description: dedent `
46
46
  Direct browser access via Playwright. Use for diagnostics and browser management.
47
47
  Actions:
48
+ - status: Inspect browser/page availability, URL, title, tab count
48
49
  - evaluate: Run JavaScript in browser context (localStorage, cookies, DOM, console)
49
50
  - closeTabs: Close all browser tabs except the current one
50
- - screenshot: Take a screenshot of current page
51
51
  - reload: Reload the current page
52
+ - screenshot: Take a screenshot of current page
53
+ - recover: Recover from a closed/crashed page using Explorer recovery
54
+ - restart: Restart the browser when page/context recovery is not enough
55
+ - openFreshTab: Open a fresh tab in the current browser context
52
56
  `,
53
57
  inputSchema: z.object({
54
- action: z.enum(['evaluate', 'closeTabs', 'screenshot', 'reload']).describe('Browser action to perform'),
58
+ action: z.enum(['status', 'evaluate', 'closeTabs', 'reload', 'screenshot', 'recover', 'restart', 'openFreshTab']).describe('Browser action to perform'),
55
59
  code: z.string().optional().describe('JavaScript code for evaluate action'),
56
60
  }),
57
61
  execute: async ({ action, code }) => {
58
- const page = ctx.explorBot.getExplorer().playwrightHelper?.page;
59
- if (!page)
60
- return { success: false, message: 'No browser page available' };
62
+ const explorer = ctx.explorBot.getExplorer();
63
+ if (action === 'status') {
64
+ const page = explorer.playwrightHelper?.page;
65
+ const pages = page?.context?.().pages?.() || [];
66
+ return {
67
+ success: true,
68
+ hasPage: !!page,
69
+ isClosed: page?.isClosed?.() || false,
70
+ url: page && !page.isClosed?.() ? await page.url() : null,
71
+ title: page && !page.isClosed?.() ? await page.title().catch(() => null) : null,
72
+ tabs: pages.length,
73
+ };
74
+ }
75
+ if (action === 'recover') {
76
+ const recovered = await explorer.recoverFromBrowserError();
77
+ return { success: recovered, message: recovered ? 'Browser page recovered' : 'Browser recovery failed' };
78
+ }
79
+ if (action === 'restart') {
80
+ const restarted = await explorer.restartBrowser();
81
+ return { success: restarted, message: restarted ? 'Browser restarted' : 'Browser restart failed' };
82
+ }
83
+ if (action === 'openFreshTab') {
84
+ await ctx.explorBot.openFreshTab();
85
+ const state = explorer.getStateManager().getCurrentState();
86
+ return { success: true, url: state?.url, title: state?.title };
87
+ }
88
+ const page = explorer.playwrightHelper?.page;
89
+ if (!page || page.isClosed?.())
90
+ return { success: false, message: 'No browser page available. Try browser({ action: "recover" }) first.' };
61
91
  if (action === 'evaluate') {
62
92
  if (!code)
63
93
  return { success: false, message: 'Code required for evaluate action' };
@@ -106,7 +136,7 @@ export function WithWebMode(Base) {
106
136
  <web_capabilities>
107
137
  - Page actions: click, pressKey, form (CodeceptJS tools)
108
138
  - Navigation: navigate() — AI-powered navigation to URLs or page descriptions
109
- - Browser diagnostics: browser() — evaluate JS, close tabs, screenshot, reload
139
+ - Browser diagnostics: browser() — inspect status, evaluate JS, close tabs, screenshot, reload, recover closed/crashed pages, restart browser, open a fresh tab
110
140
  - Visual analysis: see() — screenshot-based page verification
111
141
  - Context refresh: context() — get fresh HTML/ARIA snapshot
112
142
  - Visual fallback: visualClick() — coordinate-based click when locators fail