explorbot 0.1.27 → 0.1.29

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 (68) hide show
  1. package/README.md +83 -245
  2. package/bin/explorbot-cli.ts +1 -0
  3. package/dist/bin/explorbot-cli.js +1 -0
  4. package/dist/package.json +8 -6
  5. package/dist/rules/navigator/verification-actions.md +2 -0
  6. package/dist/src/action-result.js +2 -0
  7. package/dist/src/action.js +88 -7
  8. package/dist/src/ai/captain/file-tools.js +100 -0
  9. package/dist/src/ai/captain/idle-mode.js +70 -6
  10. package/dist/src/ai/captain/web-mode.js +36 -6
  11. package/dist/src/ai/captain.js +87 -19
  12. package/dist/src/ai/fisherman.js +14 -3
  13. package/dist/src/ai/historian/screencast.js +11 -2
  14. package/dist/src/ai/navigator.js +5 -2
  15. package/dist/src/ai/pilot.js +52 -9
  16. package/dist/src/ai/planner.js +16 -5
  17. package/dist/src/ai/provider.js +53 -18
  18. package/dist/src/ai/researcher/coordinates.js +2 -3
  19. package/dist/src/ai/researcher/deep-analysis.js +3 -4
  20. package/dist/src/ai/researcher/locators.js +1 -2
  21. package/dist/src/ai/researcher.js +24 -19
  22. package/dist/src/ai/rules.js +44 -0
  23. package/dist/src/ai/task-agent.js +1 -0
  24. package/dist/src/ai/tester.js +161 -46
  25. package/dist/src/ai/tools.js +84 -8
  26. package/dist/src/commands/explore-command.js +6 -1
  27. package/dist/src/components/LogPane.js +4 -3
  28. package/dist/src/explorbot.js +7 -2
  29. package/dist/src/explorer.js +270 -35
  30. package/dist/src/stats.js +16 -0
  31. package/dist/src/utils/aria.js +66 -6
  32. package/dist/src/utils/browser-errors.js +23 -0
  33. package/dist/src/utils/error-page.js +17 -2
  34. package/dist/src/utils/logger.js +2 -2
  35. package/package.json +8 -6
  36. package/rules/navigator/verification-actions.md +2 -0
  37. package/src/action-result.ts +2 -0
  38. package/src/action.ts +83 -7
  39. package/src/ai/captain/file-tools.ts +126 -0
  40. package/src/ai/captain/idle-mode.ts +72 -6
  41. package/src/ai/captain/mixin.ts +1 -1
  42. package/src/ai/captain/web-mode.ts +40 -5
  43. package/src/ai/captain.ts +94 -20
  44. package/src/ai/fisherman.ts +14 -3
  45. package/src/ai/historian/screencast.ts +11 -2
  46. package/src/ai/navigator.ts +6 -2
  47. package/src/ai/pilot.ts +53 -9
  48. package/src/ai/planner.ts +16 -5
  49. package/src/ai/provider.ts +51 -19
  50. package/src/ai/researcher/coordinates.ts +2 -3
  51. package/src/ai/researcher/deep-analysis.ts +3 -4
  52. package/src/ai/researcher/locators.ts +1 -2
  53. package/src/ai/researcher.ts +25 -19
  54. package/src/ai/rules.ts +46 -0
  55. package/src/ai/task-agent.ts +1 -1
  56. package/src/ai/tester.ts +175 -48
  57. package/src/ai/tools.ts +97 -8
  58. package/src/commands/explore-command.ts +6 -1
  59. package/src/components/LogPane.tsx +4 -3
  60. package/src/config.ts +1 -0
  61. package/src/explorbot.ts +6 -2
  62. package/src/explorer.ts +295 -38
  63. package/src/state-manager.ts +2 -0
  64. package/src/stats.ts +18 -0
  65. package/src/utils/aria.ts +63 -6
  66. package/src/utils/browser-errors.ts +25 -0
  67. package/src/utils/error-page.ts +16 -3
  68. package/src/utils/logger.ts +3 -3
@@ -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
@@ -18,7 +18,6 @@ import { TaskAgent } from "./task-agent.js";
18
18
  const MAX_STEPS = 15;
19
19
  const CaptainBase = WithTestMode(WithWebMode(WithIdleMode(TaskAgent)));
20
20
  export class Captain extends CaptainBase {
21
- ACTION_TOOLS = ['click', 'pressKey', 'form', 'navigate'];
22
21
  emoji = '🧑‍✈️';
23
22
  explorBot;
24
23
  conversation = null;
@@ -56,6 +55,12 @@ export class Captain extends CaptainBase {
56
55
  }
57
56
  trackToolExecutions(toolExecutions) {
58
57
  super.trackToolExecutions(toolExecutions);
58
+ if (toolExecutions.length > 0) {
59
+ this.recentToolCalls.push(...toolExecutions);
60
+ if (this.recentToolCalls.length > 20) {
61
+ this.recentToolCalls = this.recentToolCalls.slice(-20);
62
+ }
63
+ }
59
64
  for (const exec of toolExecutions) {
60
65
  const label = toolExecutionLabel(exec.input);
61
66
  if (!label)
@@ -64,15 +69,20 @@ export class Captain extends CaptainBase {
64
69
  tag('substep').log(`${icon} ${label}`);
65
70
  }
66
71
  }
67
- detectMode() {
68
- if (this.explorBot.getExplorer().activeTest)
72
+ getMode() {
73
+ const explorer = this.explorBot.getExplorer();
74
+ const activeTest = explorer.activeTest;
75
+ const page = explorer.playwrightHelper?.page;
76
+ if (activeTest && (!page || page.isClosed?.()))
77
+ return 'heal';
78
+ if (activeTest)
69
79
  return 'test';
70
- if (this.explorBot.getExplorer().getStateManager().getCurrentState())
80
+ if (explorer.getStateManager().getCurrentState())
71
81
  return 'web';
72
82
  return 'idle';
73
83
  }
74
84
  systemPrompt() {
75
- const mode = this.detectMode();
85
+ const mode = this.getMode();
76
86
  const currentUrl = this.explorBot.getExplorer().getStateManager().getCurrentState()?.url;
77
87
  const customPrompt = this.explorBot.getProvider().getSystemPromptForAgent('captain', currentUrl);
78
88
  return dedent `
@@ -85,18 +95,21 @@ export class Captain extends CaptainBase {
85
95
  - idle: plan management, file operations, knowledge. Always available.
86
96
  - web: page interaction, navigation, browser diagnostics. When working with a web page.
87
97
  - test: test analysis, state inspection. When a test is running or analyzing results.
98
+ - heal: browser/test recovery. When a test is running and browser state is broken or unavailable.
88
99
  </modes>
89
100
 
90
101
  ${this.idleModePrompt()}
91
- ${mode === 'web' ? this.webModePrompt() : ''}
92
- ${mode === 'test' ? this.testModePrompt() : ''}
102
+ ${mode === 'web' || mode === 'heal' ? this.webModePrompt() : ''}
103
+ ${mode === 'test' || mode === 'heal' ? this.testModePrompt() : ''}
93
104
 
94
105
  <rules>
95
106
  - After a successful action, if the pageDiff confirms the goal, call done() immediately — do not verify with see() or context() unless the user explicitly asked for verification
96
107
  - Prefer completing in fewer tool calls over thoroughness
97
108
  - NEVER run tests unless the user explicitly asks
98
- ${mode === 'web' ? this.webModeRules() : ''}
99
- ${mode === 'test' ? this.testModeRules() : ''}
109
+ - If you are answering with information rather than completing a browser action, include the actual user-facing answer in done({ details }). Do not only say that it was shown or explained.
110
+ ${mode === 'web' || mode === 'heal' ? this.webModeRules() : ''}
111
+ ${mode === 'test' || mode === 'heal' ? this.testModeRules() : ''}
112
+ ${mode === 'heal' ? '- First diagnose browser availability, then recover the browser/page before continuing test analysis.' : ''}
100
113
  </rules>
101
114
 
102
115
  ${customPrompt || ''}
@@ -228,9 +241,20 @@ export class Captain extends CaptainBase {
228
241
  description: 'Call when the user request is fulfilled.',
229
242
  inputSchema: z.object({
230
243
  summary: z.string().describe('What was done'),
244
+ details: z.string().optional().describe('Actual user-facing content. Required when the user asked to show, display, explain, summarize, compare, or diagnose information.'),
231
245
  }),
232
- execute: async ({ summary }) => {
246
+ execute: async ({ summary, details }) => {
233
247
  debugLog('done', summary);
248
+ if (!details?.trim() && !this.canCompleteWithoutDetails()) {
249
+ return {
250
+ success: false,
251
+ message: 'No user-facing result was provided. Call done() again with the actual answer in details, or complete a browser action first.',
252
+ };
253
+ }
254
+ if (details?.trim()) {
255
+ tag('details').log(details);
256
+ task.addNote(details);
257
+ }
234
258
  task.addNote(summary);
235
259
  onDone(summary);
236
260
  return { success: true, summary };
@@ -239,6 +263,9 @@ export class Captain extends CaptainBase {
239
263
  runCommand: tool({
240
264
  description: dedent `
241
265
  Execute a TUI command. Returns log output from command execution.
266
+ Use only when the user explicitly asks to run a slash command.
267
+ Never use this to analyze files, reports, logs, plans, generated tests, knowledge, or experience.
268
+ Never run a slash command unless the user request itself starts with that slash command.
242
269
  ${this.commandDescriptions
243
270
  .map((c) => {
244
271
  const opts = c.options ? ` (${c.options})` : '';
@@ -253,6 +280,13 @@ export class Captain extends CaptainBase {
253
280
  if (!this.commandExecutor)
254
281
  return { success: false, message: 'Command executor not available' };
255
282
  const cmd = command.startsWith('/') ? command : `/${command}`;
283
+ if (!isExplicitSlashRequest(task.description, cmd)) {
284
+ return {
285
+ success: false,
286
+ command: cmd,
287
+ message: 'Command blocked: slash commands require an explicit matching slash-command request from the user.',
288
+ };
289
+ }
256
290
  startLogCapture();
257
291
  try {
258
292
  await this.commandExecutor(cmd);
@@ -265,10 +299,12 @@ export class Captain extends CaptainBase {
265
299
  };
266
300
  }
267
301
  async tools(task, onDone) {
268
- const mode = this.detectMode();
302
+ const mode = this.getMode();
269
303
  const ctx = { explorBot: this.explorBot, task };
270
304
  const core = this.coreTools(task, onDone);
271
305
  const idle = await this.idleModeTools(ctx);
306
+ if (mode === 'heal')
307
+ return { ...core, ...idle, ...this.testModeTools(ctx), ...this.webModeTools(ctx) };
272
308
  if (mode === 'test')
273
309
  return { ...core, ...idle, ...this.testModeTools(ctx) };
274
310
  if (mode === 'web')
@@ -333,17 +369,24 @@ export class Captain extends CaptainBase {
333
369
  }
334
370
  return result.object;
335
371
  }
372
+ async processExecutionError(error, activeTest) {
373
+ const explorer = this.explorBot.getExplorer();
374
+ const result = await explorer.handleExecutionError(error);
375
+ return {
376
+ ...result,
377
+ message: result.recovered ? `${result.message}\nContinue the test "${activeTest.scenario}" from the restored page.` : result.message,
378
+ };
379
+ }
380
+ canCompleteWithoutDetails() {
381
+ return (this.recentToolCalls || []).some(hasBrowserCompletionEvidence);
382
+ }
336
383
  async handle(input, options = {}) {
337
384
  const stateManager = this.explorBot.getExplorer().getStateManager();
338
385
  const initialState = stateManager.getCurrentState();
339
- if (!initialState) {
340
- tag('warning').log('No page loaded. Use /navigate or I.amOnPage() first.');
341
- return null;
342
- }
343
386
  const conversation = options.reset ? this.resetConversation() : this.ensureConversation();
344
387
  let isDone = false;
345
388
  let finalSummary = null;
346
- const startUrl = initialState.url || '';
389
+ const startUrl = initialState?.url || '';
347
390
  const task = new Task(input, startUrl);
348
391
  const onDone = (summary) => {
349
392
  isDone = true;
@@ -376,11 +419,13 @@ export class Captain extends CaptainBase {
376
419
  return;
377
420
  }
378
421
  const currentState = stateManager.getCurrentState();
379
- if (!currentState) {
422
+ if (!currentState && this.getMode() !== 'idle') {
380
423
  stop();
381
424
  return;
382
425
  }
383
- await this.reinjectContextIfNeeded(conversation, currentState);
426
+ if (currentState) {
427
+ await this.reinjectContextIfNeeded(conversation, currentState);
428
+ }
384
429
  if (userInput) {
385
430
  const newContext = await this.getPageContext();
386
431
  conversation.addUserText(dedent `
@@ -410,7 +455,7 @@ export class Captain extends CaptainBase {
410
455
  }
411
456
  if (result?.toolExecutions?.length) {
412
457
  const lastExec = result.toolExecutions[result.toolExecutions.length - 1];
413
- if (lastExec.wasSuccessful && this.ACTION_TOOLS.includes(lastExec.toolName)) {
458
+ if (hasBrowserCompletionEvidence(lastExec)) {
414
459
  conversation.addUserText('Action succeeded. If the goal is achieved, call done() now with a brief summary.');
415
460
  }
416
461
  }
@@ -437,3 +482,26 @@ export class Captain extends CaptainBase {
437
482
  }
438
483
  }
439
484
  export default Captain;
485
+ function isExplicitSlashRequest(input, command) {
486
+ const requested = slashCommandToken(input);
487
+ const actual = slashCommandToken(command);
488
+ if (!requested || !actual)
489
+ return false;
490
+ return requested === actual;
491
+ }
492
+ function slashCommandToken(value) {
493
+ const trimmed = value.trim();
494
+ if (!trimmed.startsWith('/'))
495
+ return null;
496
+ for (let i = 1; i < trimmed.length; i++) {
497
+ if (trimmed[i] <= ' ')
498
+ return trimmed.slice(0, i);
499
+ }
500
+ return trimmed;
501
+ }
502
+ function hasBrowserCompletionEvidence(execution) {
503
+ if (!execution?.wasSuccessful)
504
+ return false;
505
+ const output = execution.output || {};
506
+ return Boolean(output.pageDiff || output.code || output.playwrightGroupId);
507
+ }