@hamp10/agentforge 0.2.23 → 0.2.25

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hamp10/agentforge",
3
- "version": "0.2.23",
3
+ "version": "0.2.25",
4
4
  "description": "AgentForge worker — connect your machine to agentforge.ai",
5
5
  "type": "module",
6
6
  "bin": {
@@ -6,7 +6,7 @@ import { existsSync, lstatSync, mkdtempSync, mkdirSync, readFileSync, readlinkSy
6
6
  import { tmpdir } from 'node:os';
7
7
  import path from 'node:path';
8
8
  import { deflateSync } from 'node:zlib';
9
- import { extractExplicitScope } from '../src/taskSemantics.js';
9
+ import { extractExplicitScope, scopeSlugAliases, scopeSlugsMatchingText } from '../src/taskSemantics.js';
10
10
  import { AgentForgeWorker } from '../src/worker.js';
11
11
  import { OpenClawCLI } from '../src/OpenClawCLI.js';
12
12
 
@@ -23,6 +23,12 @@ const cases = [
23
23
  absent: ['example-com', 'visual', 'quality'],
24
24
  pageOnly: true,
25
25
  },
26
+ {
27
+ text: 'Work on the Example.com listing pages for AlphaBoard.ai and BetaMatch.ai. Make them visually excellent and consistent with the rest of Example.com. Only change those two listing pages.',
28
+ slugs: ['alphaboard-ai', 'betamatch-ai'],
29
+ absent: ['example-com', 'visual', 'excellent'],
30
+ pageOnly: true,
31
+ },
26
32
  {
27
33
  text: 'Work on the Example.com listing pages for AlphaBoard and BetaMatch. Delete and rebuild those two listing page implementations from a clean start, preserving the same URLs and site conventions. Fix the readability and design issues. Only change those two listing pages.',
28
34
  slugs: ['alphaboard', 'betamatch'],
@@ -85,6 +91,17 @@ for (const item of cases) {
85
91
  }
86
92
  }
87
93
 
94
+ assert.deepEqual(
95
+ scopeSlugAliases('AgentBoard.ai'),
96
+ ['agentboard-ai', 'agentboard'],
97
+ 'domain/page scope aliases should let page-owned assets omit the public suffix'
98
+ );
99
+ assert.deepEqual(
100
+ scopeSlugsMatchingText('public_html/css/agentboard.css', ['agentboard-ai']),
101
+ ['agentboard-ai'],
102
+ 'scoped ownership should match page-owned assets named after the product, not only exact domain slugs'
103
+ );
104
+
88
105
  const git = (cwd, args) => execFileSync('git', args, { cwd, encoding: 'utf-8', stdio: 'pipe' }).trim();
89
106
 
90
107
  const writeRgbPng = (filePath, width, height, sample) => {
@@ -382,9 +399,19 @@ try {
382
399
  assert.match(
383
400
  worker._formatScopeDriftNudge(worker._findScopeDriftRepoChanges(baseline, message)),
384
401
  /domain-detail\.css/i,
385
- 'shared stylesheet files should still be reported as out-of-scope for scoped page work'
402
+ 'new shared-looking stylesheet files should be reported as out-of-scope until scoped pages own/link them'
403
+ );
404
+ writeFileSync(
405
+ alphaFixturePath,
406
+ alphaFixtureCurrent.replace('</head>', '<link rel="stylesheet" href="../css/domain-detail.css">\n</head>')
407
+ );
408
+ assert.equal(
409
+ worker._findScopeDriftRepoChanges(baseline, message).some(w => w.files.includes('public_html/css/domain-detail.css')),
410
+ false,
411
+ 'new stylesheet files linked only by scoped pages should be treated as page-owned scoped assets'
386
412
  );
387
413
  rmSync(sharedCssFile, { force: true });
414
+ writeFileSync(alphaFixturePath, alphaFixtureCurrent);
388
415
  const directOldHtml = '<!doctype html><html><body><main><section class="hero"><h1>Alpha</h1><p>Existing body copy.</p></section></main></body></html>';
389
416
  const directInlineStyleHtml = '<!doctype html><html><body><main><section class="hero" style="padding: 4rem; color: white;"><h1>Alpha</h1><p>Existing body copy.</p></section></main></body></html>';
390
417
  assert.throws(
@@ -579,6 +606,14 @@ try {
579
606
  ),
580
607
  'scoped shell touch should allow page-owned CSS files'
581
608
  );
609
+ assert.doesNotThrow(
610
+ () => cli._guardDirectBashCommand(
611
+ 'touch public_html/css/alpha.css',
612
+ fixture.repo,
613
+ { task: 'Work on the Example.com listing pages for Alpha.ai and Beta.ai. Only change those listing pages and directly owned assets.' }
614
+ ),
615
+ 'scoped shell touch should allow page-owned CSS files that use the product name without the domain suffix'
616
+ );
582
617
  assert.doesNotThrow(
583
618
  () => cli._guardDirectBashCommand(
584
619
  "sed -i '' 's/Existing/Updated/g' public_html/domains/alpha.html",
@@ -587,14 +622,23 @@ try {
587
622
  ),
588
623
  'scoped shell sed should ignore the substitution script and allow canonical target page edits'
589
624
  );
590
- assert.throws(
625
+ assert.doesNotThrow(
591
626
  () => cli._guardDirectBashCommand(
592
627
  'touch public_html/css/domain-detail.css',
593
628
  fixture.repo,
594
629
  { task: message }
595
630
  ),
596
- /outside the requested task scope/i,
597
- 'scoped shell touch should reject shared CSS files outside the target names'
631
+ 'direct scoped work should allow creating a new page-owned CSS asset before the target page links it'
632
+ );
633
+ const staleLinkedCssHtml = '<!doctype html><html><head><link rel="stylesheet" href="css/alpha.css"></head><body><main><section><h1>Alpha</h1><p>Fresh page body copy with rebuilt structure.</p></section></main></body></html>';
634
+ assert.throws(
635
+ () => cli._validateDirectUiFileContent(
636
+ path.join(fixture.repo, 'public_html', 'domains', 'alpha.html'),
637
+ staleLinkedCssHtml,
638
+ { task: 'Delete and rebuild the listing pages for Alpha.ai and Beta.ai from a clean start. Only change those listing pages and directly owned assets.' }
639
+ ),
640
+ /still links unchanged old page-owned stylesheet/i,
641
+ 'clean-start HTML rebuilds should not keep linking unchanged old page-owned stylesheets'
598
642
  );
599
643
  const seamPng = path.join(fixture.repo, 'dominant-seam.png');
600
644
  writeRgbPng(seamPng, 900, 600, (x, y) => (
@@ -604,8 +648,8 @@ try {
604
648
  ));
605
649
  assert.match(
606
650
  cli._detectScreenshotVisualDiscontinuities(seamPng).join('\n'),
607
- /hard-edged/i,
608
- 'screenshot discontinuity scan should catch large hard-edged rectangular seams'
651
+ /screenshot continuity break/i,
652
+ 'screenshot discontinuity scan should catch large rectangular visual discontinuities'
609
653
  );
610
654
  const subtlePartialSurfacePng = path.join(fixture.repo, 'subtle-partial-surface.png');
611
655
  writeRgbPng(subtlePartialSurfacePng, 1000, 700, (x, y) => {
@@ -614,8 +658,8 @@ try {
614
658
  });
615
659
  assert.match(
616
660
  cli._detectScreenshotVisualDiscontinuities(subtlePartialSurfacePng).join('\n'),
617
- /partial-surface hard-edged background\/tint block edge/i,
618
- 'screenshot discontinuity scan should catch large subtle partial-surface tint blocks'
661
+ /partial-surface screenshot continuity break/i,
662
+ 'screenshot discontinuity scan should catch large subtle partial-surface visual discontinuities'
619
663
  );
620
664
  } finally {
621
665
  rmSync(fixture.repo, { recursive: true, force: true });
@@ -625,6 +669,8 @@ const openClawSource = readFileSync(new URL('../src/OpenClawCLI.js', import.meta
625
669
  const workerSource = readFileSync(new URL('../src/worker.js', import.meta.url), 'utf-8');
626
670
  const workerBinSource = readFileSync(new URL('../bin/agentforge.js', import.meta.url), 'utf-8');
627
671
  const defaultGuidesSource = readFileSync(new URL('../src/default-task-guides.js', import.meta.url), 'utf-8');
672
+ const browserSource = readFileSync(new URL('../src/browser.js', import.meta.url), 'utf-8');
673
+ const previewServerSource = readFileSync(new URL('../src/preview-server.js', import.meta.url), 'utf-8');
628
674
  const serverSource = readFileSync(new URL('../../../src/web-server-auth.js', import.meta.url), 'utf-8');
629
675
  const dashboardSource = readFileSync(new URL('../../../public/dashboard.js', import.meta.url), 'utf-8');
630
676
  assert.match(
@@ -644,13 +690,13 @@ assert.match(
644
690
  );
645
691
  assert.match(
646
692
  openClawSource,
647
- /large hard-edged rectangular background\/overlay boundary across a dominant UI surface/i,
648
- 'screenshot discontinuity detection should fail large hard-edged visual breaks'
693
+ /visible screenshot continuity break across a dominant UI surface/i,
694
+ 'screenshot discontinuity detection should fail large visual continuity breaks'
649
695
  );
650
696
  assert.match(
651
697
  openClawSource,
652
- /partial-surface hard-edged background\/tint block edge/i,
653
- 'screenshot discontinuity detection should fail subtle partial-surface tint blocks'
698
+ /partial-surface screenshot continuity break/i,
699
+ 'screenshot discontinuity detection should fail subtle partial-surface visual continuity breaks'
654
700
  );
655
701
  assert.match(
656
702
  openClawSource,
@@ -822,6 +868,26 @@ assert.match(
822
868
  /requiresComparableVisualContextForMutation/i,
823
869
  'scoped UI work should require visual reference context when local peer pages can be opened'
824
870
  );
871
+ assert.match(
872
+ openClawSource,
873
+ /comparableSourceContextCountForMutation[\s\S]*item\.kind === 'source'/i,
874
+ 'scoped UI comparable context should require source/code inspection, not count browser-only views as source context'
875
+ );
876
+ assert.match(
877
+ openClawSource,
878
+ /localBrowserContextUrls[\s\S]*directReferenceInspections[\s\S]*kind === 'browser'[\s\S]*sourceCandidateUrlPaths/i,
879
+ 'comparable visual context should use local browser reference URLs, not only already-opened target URLs'
880
+ );
881
+ assert.match(
882
+ openClawSource,
883
+ /if \(!invalidScopedLoad\) rememberLocalScopedUrl/i,
884
+ 'target pages with design warnings should still seed local peer URL discovery for visual context'
885
+ );
886
+ assert.match(
887
+ openClawSource,
888
+ /public_html[\s\S]*rootMappedUrl/i,
889
+ 'visual peer URL suggestions should map static roots like public_html to browser paths'
890
+ );
825
891
  assert.match(
826
892
  openClawSource,
827
893
  /_directUiContextByTask/i,
@@ -844,7 +910,42 @@ assert.match(
844
910
  );
845
911
  assert.match(
846
912
  openClawSource,
847
- /open at least one nearby peer page from the same app\/site and inspect it visually/i,
913
+ /Network\.setCacheDisabled[\s\S]*waitForRequestedDocument/i,
914
+ 'direct browser verification should bypass stale local cache and wait for the requested page before judging UI'
915
+ );
916
+ assert.match(
917
+ openClawSource,
918
+ /__agentforge_verify/i,
919
+ 'direct browser verification should cache-bust local verification URLs'
920
+ );
921
+ assert.match(
922
+ openClawSource,
923
+ /forgetLocalScopedUrl[\s\S]*rendered page content did not identify target|rendered page content did not identify target[\s\S]*forgetLocalScopedUrl/i,
924
+ 'wrong-page scoped browser results should invalidate remembered local preview URLs'
925
+ );
926
+ assert.match(
927
+ browserSource,
928
+ /setCacheEnabled\(false\)[\s\S]*waitForPageReady/i,
929
+ 'AgentForge browser tool should bypass stale localhost cache and wait for local navigation readiness'
930
+ );
931
+ assert.match(
932
+ browserSource,
933
+ /__agentforge_verify/i,
934
+ 'AgentForge browser tool should cache-bust localhost navigations'
935
+ );
936
+ assert.match(
937
+ previewServerSource,
938
+ /extname\(urlPath\)[\s\S]*writeHead\(404[\s\S]*no-store/i,
939
+ 'preview server should not silently SPA-fallback explicit missing asset/page requests'
940
+ );
941
+ assert.match(
942
+ previewServerSource,
943
+ /Cache-Control': 'no-store, no-cache, must-revalidate, proxy-revalidate'/,
944
+ 'preview server responses should disable browser/proxy caching during visual QA'
945
+ );
946
+ assert.match(
947
+ openClawSource,
948
+ /Open at least one nearby peer page from the same app\/site in the browser and inspect it visually/i,
848
949
  'comparable UI context guidance should require visual inspection, not source-only matching'
849
950
  );
850
951
  assert.doesNotMatch(
@@ -857,6 +958,16 @@ assert.match(
857
958
  /const minRatio = isLargeText \? 3 : 4\.5;/,
858
959
  'control/link contrast checks should use text-size-aware WCAG thresholds'
859
960
  );
961
+ assert.match(
962
+ dashboardSource,
963
+ /typingEl\.dataset\.startedAt\s*=\s*startedAt/,
964
+ 'live guide insertion should preserve the active feed start time for chronological reload ordering'
965
+ );
966
+ assert.match(
967
+ dashboardSource,
968
+ /time:\s*startedAt/,
969
+ 'completed live feeds should save with their start time so guide bubbles appear after the feed they interrupted'
970
+ );
860
971
  assert.match(
861
972
  defaultGuidesSource,
862
973
  /anything that reads as rendering damage rather than deliberate design/i,
@@ -11,6 +11,7 @@ import {
11
11
  extractExplicitScope,
12
12
  extractNamedPageScopeSlugs,
13
13
  hasExplicitScopeRestriction,
14
+ scopeSlugsMatchingText,
14
15
  scopeSlug,
15
16
  } from './taskSemantics.js';
16
17
  import treeKill from 'tree-kill';
@@ -588,6 +589,10 @@ export class OpenClawCLI extends EventEmitter {
588
589
  return scopeSlug(value);
589
590
  }
590
591
 
592
+ _directScopeSlugsMatchingText(value, slugs) {
593
+ return scopeSlugsMatchingText(value, slugs);
594
+ }
595
+
591
596
  _addDirectScopeCandidate(candidates, value) {
592
597
  return addScopeCandidate(candidates, value);
593
598
  }
@@ -604,10 +609,22 @@ export class OpenClawCLI extends EventEmitter {
604
609
  return /\.(html?|jsx?|tsx?|vue|svelte|astro|mdx?|css|scss|sass|less)$/i.test(String(relativePath || ''));
605
610
  }
606
611
 
612
+ _isDirectPageAssetPath(relativePath) {
613
+ return /\.(css|scss|sass|less|jsx?|tsx?)$/i.test(String(relativePath || ''));
614
+ }
615
+
607
616
  _isDirectHtmlPagePath(relativePath) {
608
617
  return /\.(?:html?|xhtml|astro|mdx?)$/i.test(String(relativePath || ''));
609
618
  }
610
619
 
620
+ _isDirectNewPageOwnedAssetPath(filePath, workDir, relativePath, pageOnly) {
621
+ if (!pageOnly || !this._isDirectPageAssetPath(relativePath)) return false;
622
+ if (existsSync(filePath)) return false;
623
+ const relative = path.relative(workDir, filePath);
624
+ if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) return false;
625
+ return true;
626
+ }
627
+
611
628
  _directTaskScopeAllowsPath(filePath, workDir, task) {
612
629
  const { slugs, pageOnly } = this._extractDirectExplicitScope(task);
613
630
  if (slugs.length === 0) return true;
@@ -616,8 +633,10 @@ export class OpenClawCLI extends EventEmitter {
616
633
  ? filePath
617
634
  : relative;
618
635
  const lower = String(scopePath || '').toLowerCase();
619
- const slugAllowed = slugs.some(slug => lower.includes(slug));
620
- if (!slugAllowed) return false;
636
+ const slugAllowed = this._directScopeSlugsMatchingText(lower, slugs).length > 0;
637
+ if (!slugAllowed) {
638
+ return this._isDirectNewPageOwnedAssetPath(filePath, workDir, scopePath, pageOnly);
639
+ }
621
640
  return !pageOnly || this._isDirectPageSourcePath(scopePath);
622
641
  }
623
642
 
@@ -645,7 +664,7 @@ export class OpenClawCLI extends EventEmitter {
645
664
  if (!entry.isFile() || !this._isDirectPageSourcePath(entry.name)) continue;
646
665
  const rel = path.relative(workDir, fullPath) || fullPath;
647
666
  const lower = rel.toLowerCase();
648
- if (slugs.some(slug => lower.includes(slug)) && !results.includes(rel)) results.push(rel);
667
+ if (this._directScopeSlugsMatchingText(lower, slugs).length > 0 && !results.includes(rel)) results.push(rel);
649
668
  }
650
669
  };
651
670
  walk(workDir, 0);
@@ -670,7 +689,8 @@ export class OpenClawCLI extends EventEmitter {
670
689
  `Requested path: ${relative || filePath}`,
671
690
  `Allowed scope tokens: ${slugs.join(', ')}${pageOnly ? ' (page/source files only)' : ''}.`,
672
691
  candidateText.trim(),
673
- 'Use a target file inside the requested scope, or restore out-of-scope changes with git if you are cleaning up a previous mistake.',
692
+ 'Use a target file inside the requested scope, a page-local <style> block, or a new page-owned CSS/JS asset that is linked only from the requested target pages.',
693
+ 'Do not edit shared/global files such as universal, base, reset, layout, or component styles unless the user explicitly asks for shared-site changes.',
674
694
  ].filter(Boolean).join(' ');
675
695
  }
676
696
 
@@ -806,7 +826,7 @@ export class OpenClawCLI extends EventEmitter {
806
826
  throw this._directScopeViolationError(this._formatDirectScopeError(filePath, workDir, options.task));
807
827
  }
808
828
  if (pageOnly && slugs.length > 0 && this._isDirectHtmlPagePath(relativePath)) {
809
- const matchedSlugs = slugs.filter(slug => lowerRelativePath.includes(slug));
829
+ const matchedSlugs = this._directScopeSlugsMatchingText(lowerRelativePath, slugs);
810
830
  const isTrackedTarget = this._gitTracksExactPath(workDir, relativePath);
811
831
  if (matchedSlugs.length > 0 && !existsSync(filePath) && !isTrackedTarget) {
812
832
  const existingPages = this._directScopeFileCandidates(workDir, matchedSlugs, 20)
@@ -924,7 +944,7 @@ export class OpenClawCLI extends EventEmitter {
924
944
  const { slugs, pageOnly } = this._extractDirectExplicitScope(options?.task);
925
945
  if (!pageOnly || slugs.length === 0) return;
926
946
  const lowerPath = String(filePath || '').toLowerCase();
927
- const matchingSlugs = slugs.filter(slug => lowerPath.includes(slug));
947
+ const matchingSlugs = this._directScopeSlugsMatchingText(lowerPath, slugs);
928
948
  if (matchingSlugs.length === 0) return;
929
949
 
930
950
  const root = this._gitRootForPath(filePath);
@@ -942,7 +962,7 @@ export class OpenClawCLI extends EventEmitter {
942
962
  const targetPageFiles = trackedFiles.filter(rel => {
943
963
  const lower = rel.toLowerCase();
944
964
  if (!/\.(?:html?|xhtml|jsx?|tsx?|vue|svelte|astro|mdx?)$/i.test(lower)) return false;
945
- return matchingSlugs.some(slug => lower.includes(slug));
965
+ return scopeSlugsMatchingText(lower, matchingSlugs).length > 0;
946
966
  });
947
967
  if (targetPageFiles.length === 0) return;
948
968
 
@@ -1011,10 +1031,77 @@ export class OpenClawCLI extends EventEmitter {
1011
1031
  }
1012
1032
  }
1013
1033
 
1034
+ _validateDirectCleanStartHtmlDoesNotReuseOldPageStylesheet(filePath, content, options = {}) {
1035
+ if (!this._isDirectBroadUiQualityTask(options?.task)) return;
1036
+ if (!/\.(?:html?|xhtml|astro|mdx?)$/i.test(String(filePath || ''))) return;
1037
+ const taskText = String(options?.task || '');
1038
+ const isCleanStartTask = /\b(?:delete|rebuild|clean start|from scratch|start over|fresh|remake)\b/i.test(taskText);
1039
+ if (!isCleanStartTask) return;
1040
+ const { slugs, pageOnly } = this._extractDirectExplicitScope(options?.task);
1041
+ if (!pageOnly || slugs.length === 0) return;
1042
+
1043
+ const matchingSlugs = this._directScopeSlugsMatchingText(filePath, slugs);
1044
+ if (matchingSlugs.length === 0) return;
1045
+
1046
+ const root = this._gitRootForPath(filePath);
1047
+ if (!root) return;
1048
+
1049
+ let pageDir = path.dirname(filePath);
1050
+ try {
1051
+ pageDir = path.dirname(realpathSync(filePath));
1052
+ } catch {}
1053
+
1054
+ const staleLinks = [];
1055
+ const html = String(content || '');
1056
+ for (const tagMatch of html.matchAll(/<link\b[^>]*>/gi)) {
1057
+ const tag = tagMatch[0] || '';
1058
+ if (!/\brel\s*=\s*["'][^"']*\bstylesheet\b[^"']*["']/i.test(tag)) continue;
1059
+ const href = tag.match(/\bhref\s*=\s*["']([^"']+)["']/i)?.[1] || '';
1060
+ if (!href || /^(?:https?:|data:|blob:|\/\/)/i.test(href)) continue;
1061
+ const cleanHref = href.split(/[?#]/)[0];
1062
+ if (!cleanHref || !/\.css$/i.test(cleanHref)) continue;
1063
+ const absolute = path.resolve(pageDir, cleanHref);
1064
+ const rel = path.relative(root, absolute).replace(/\\/g, '/');
1065
+ if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) continue;
1066
+ if (this._directScopeSlugsMatchingText(rel, matchingSlugs).length === 0) continue;
1067
+
1068
+ let headCss = null;
1069
+ try {
1070
+ headCss = String(execFileSync('git', ['-C', root, 'show', `HEAD:${rel}`], {
1071
+ encoding: 'utf-8',
1072
+ maxBuffer: 5 * 1024 * 1024,
1073
+ stdio: ['ignore', 'pipe', 'ignore'],
1074
+ }) || '');
1075
+ } catch {
1076
+ continue;
1077
+ }
1078
+ if (!existsSync(absolute)) continue;
1079
+ let currentCss = '';
1080
+ try {
1081
+ currentCss = readFileSync(absolute, 'utf-8');
1082
+ } catch {
1083
+ continue;
1084
+ }
1085
+ if (this._cssComparableLines(headCss).join('\n') === this._cssComparableLines(currentCss).join('\n')) {
1086
+ staleLinks.push(rel);
1087
+ }
1088
+ }
1089
+
1090
+ if (staleLinks.length === 0) return;
1091
+ const err = new Error([
1092
+ 'Rejected clean-start page rebuild because the new HTML still links unchanged old page-owned stylesheet(s).',
1093
+ `Stale stylesheet(s): ${[...new Set(staleLinks)].slice(0, 4).join(', ')}.`,
1094
+ 'For clean-start scoped page work, delete or materially rewrite old page-owned stylesheets, or move the design into a fresh page-local style block before linking the rebuilt page.',
1095
+ ].join(' '));
1096
+ err.code = 'AGENTFORGE_UI_STALE_PAGE_STYLESHEET';
1097
+ throw err;
1098
+ }
1099
+
1014
1100
  _validateDirectUiImplementationArtifacts(filePath, content, options = {}) {
1015
1101
  if (!this._isDirectBroadUiQualityTask(options?.task)) return;
1016
1102
  if (!/\.(?:html?|xhtml|css|s[ac]ss|jsx?|tsx?|vue|svelte|astro|mdx?)$/i.test(String(filePath || ''))) return;
1017
1103
  this._validateDirectPageOwnedStylesheetIsFresh(filePath, content, options);
1104
+ this._validateDirectCleanStartHtmlDoesNotReuseOldPageStylesheet(filePath, content, options);
1018
1105
 
1019
1106
  const oldText = String(options?.oldContent || '');
1020
1107
  const headText = this._gitHeadContentForPath(filePath);
@@ -1220,7 +1307,7 @@ export class OpenClawCLI extends EventEmitter {
1220
1307
  .filter(line => {
1221
1308
  if (slugs.length === 0) return true;
1222
1309
  const lower = line.toLowerCase();
1223
- return slugs.some(slug => lower.includes(slug));
1310
+ return scopeSlugsMatchingText(lower, slugs).length > 0;
1224
1311
  });
1225
1312
  if (scopedLines.length === 0) return '';
1226
1313
 
@@ -2662,6 +2749,29 @@ export class OpenClawCLI extends EventEmitter {
2662
2749
  return String(value || '').replace(/\/$/, '');
2663
2750
  }
2664
2751
  };
2752
+ const stripAgentForgeCacheBust = (value) => {
2753
+ try {
2754
+ const parsed = new URL(value);
2755
+ parsed.searchParams.delete('__agentforge_verify');
2756
+ return parsed.href.replace(/\/$/, '');
2757
+ } catch {
2758
+ return String(value || '').replace(/([?&])__agentforge_verify=[^&#]+&?/, '$1').replace(/[?&]$/, '').replace(/\/$/, '');
2759
+ }
2760
+ };
2761
+ const normalizeForNavigationCompare = (value) => stripAgentForgeCacheBust(normalize(value));
2762
+ const isLocalHttpUrl = (value) =>
2763
+ /^https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\])(?::\d+)?(?:\/|$)/i.test(String(value || ''));
2764
+ const withVerificationCacheBust = (value) => {
2765
+ if (!value || !isLocalHttpUrl(value)) return value;
2766
+ try {
2767
+ const parsed = new URL(value);
2768
+ parsed.searchParams.set('__agentforge_verify', `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
2769
+ return parsed.href;
2770
+ } catch {
2771
+ const sep = String(value).includes('?') ? '&' : '?';
2772
+ return `${value}${sep}__agentforge_verify=${Date.now()}`;
2773
+ }
2774
+ };
2665
2775
 
2666
2776
  const listResp = await fetch(`${cdpBase}/json/list`, { signal: AbortSignal.timeout(5_000) });
2667
2777
  if (!listResp.ok) throw new Error(`CDP list failed: HTTP ${listResp.status}`);
@@ -2674,6 +2784,9 @@ export class OpenClawCLI extends EventEmitter {
2674
2784
  /^https?:\/\/agentforgeai-production\.up\.railway\.app\/dashboard(?:[/?#]|$)/i.test(String(value || ''));
2675
2785
  const rememberedUrl = this._directBrowserLastUrlByAgent.get(agentId) || null;
2676
2786
  const effectiveRequestedUrl = requestedUrl || rememberedUrl || null;
2787
+ const navigationRequestedUrl = options?.afterMutation
2788
+ ? withVerificationCacheBust(effectiveRequestedUrl)
2789
+ : effectiveRequestedUrl;
2677
2790
  const usablePages = !taskIsAboutAgentForge
2678
2791
  ? pages.filter(p => !isAgentForgeControlSurface(p.url))
2679
2792
  : pages;
@@ -2690,7 +2803,7 @@ export class OpenClawCLI extends EventEmitter {
2690
2803
  }
2691
2804
 
2692
2805
  if (!page) {
2693
- const newUrl = effectiveRequestedUrl || 'about:blank';
2806
+ const newUrl = navigationRequestedUrl || effectiveRequestedUrl || 'about:blank';
2694
2807
  const newResp = await fetch(`${cdpBase}/json/new?${encodeURIComponent(newUrl)}`, {
2695
2808
  method: 'PUT',
2696
2809
  signal: AbortSignal.timeout(5_000),
@@ -2749,12 +2862,42 @@ export class OpenClawCLI extends EventEmitter {
2749
2862
  try {
2750
2863
  await cdp('Page.enable');
2751
2864
  await cdp('Runtime.enable');
2752
- if (effectiveRequestedUrl && normalize(page.url) !== normalize(effectiveRequestedUrl)) {
2753
- await cdp('Page.navigate', { url: effectiveRequestedUrl });
2754
- await new Promise(r => setTimeout(r, 2_000));
2865
+ await cdp('Network.enable').catch(() => null);
2866
+ await cdp('Network.setCacheDisabled', { cacheDisabled: true }).catch(() => null);
2867
+ const waitForRequestedDocument = async (expectedUrl) => {
2868
+ const expectedComparable = expectedUrl ? normalizeForNavigationCompare(expectedUrl) : '';
2869
+ let lastState = null;
2870
+ for (let i = 0; i < 45; i++) {
2871
+ try {
2872
+ const stateResult = await cdp('Runtime.evaluate', {
2873
+ expression: `(() => ({ href: location.href, readyState: document.readyState, bodyLength: document.body ? document.body.innerText.length : 0 }))()`,
2874
+ awaitPromise: true,
2875
+ returnByValue: true,
2876
+ });
2877
+ lastState = stateResult?.result?.value || null;
2878
+ const currentComparable = normalizeForNavigationCompare(lastState?.href || '');
2879
+ const urlReady = !expectedComparable || currentComparable === expectedComparable;
2880
+ const documentReady = lastState?.readyState && lastState.readyState !== 'loading';
2881
+ if (urlReady && documentReady) return lastState;
2882
+ } catch {
2883
+ // The old page context can disappear while the new document commits.
2884
+ }
2885
+ await new Promise(r => setTimeout(r, 200));
2886
+ }
2887
+ if (options?.afterMutation && expectedComparable && lastState?.href) {
2888
+ const currentComparable = normalizeForNavigationCompare(lastState.href);
2889
+ if (currentComparable !== expectedComparable) {
2890
+ throw new Error(`Browser failed to load requested verification URL ${stripAgentForgeCacheBust(expectedUrl)}; current tab is ${stripAgentForgeCacheBust(lastState.href)}`);
2891
+ }
2892
+ }
2893
+ return lastState;
2894
+ };
2895
+ if (effectiveRequestedUrl && normalizeForNavigationCompare(page.url) !== normalizeForNavigationCompare(effectiveRequestedUrl)) {
2896
+ await cdp('Page.navigate', { url: navigationRequestedUrl || effectiveRequestedUrl });
2897
+ await waitForRequestedDocument(effectiveRequestedUrl);
2755
2898
  } else if (effectiveRequestedUrl) {
2756
2899
  await cdp('Page.reload', { ignoreCache: true });
2757
- await new Promise(r => setTimeout(r, 1_500));
2900
+ await waitForRequestedDocument(effectiveRequestedUrl);
2758
2901
  }
2759
2902
  if (effectiveRequestedUrl) {
2760
2903
  await cdp('Runtime.evaluate', {
@@ -2985,7 +3128,7 @@ export class OpenClawCLI extends EventEmitter {
2985
3128
  horizontal.y < height * 0.94;
2986
3129
  if (!awayFromViewportEdge) continue;
2987
3130
  warnings.push([
2988
- `large hard-edged rectangular background/overlay boundary across a dominant UI surface near x=${vertical.x}, y=${horizontal.y}`,
3131
+ `visible screenshot continuity break across a dominant UI surface near x=${vertical.x}, y=${horizontal.y}`,
2989
3132
  `(vertical span y=${vertical.minY}-${vertical.maxY}, horizontal span x=${horizontal.minX}-${horizontal.maxX})`,
2990
3133
  ].join(' '));
2991
3134
  break;
@@ -3001,7 +3144,7 @@ export class OpenClawCLI extends EventEmitter {
3001
3144
  edge.average >= 38
3002
3145
  );
3003
3146
  if (dominantVertical) {
3004
- warnings.push(`large hard-edged vertical background/overlay seam across a dominant UI surface near x=${dominantVertical.x} spanning y=${dominantVertical.minY}-${dominantVertical.maxY}`);
3147
+ warnings.push(`visible vertical screenshot continuity break across a dominant UI surface near x=${dominantVertical.x} spanning y=${dominantVertical.minY}-${dominantVertical.maxY}`);
3005
3148
  }
3006
3149
  }
3007
3150
 
@@ -3054,7 +3197,7 @@ export class OpenClawCLI extends EventEmitter {
3054
3197
  }
3055
3198
 
3056
3199
  if (partialHorizontal) {
3057
- warnings.push(`large partial-surface hard-edged background/tint block edge near y=${partialHorizontal.y}, running from x=${partialHorizontal.start} to x=${partialHorizontal.end}. This often indicates an unintended rectangular overlay, cropped layer, or mismatched background plane.`);
3200
+ warnings.push(`visible partial-surface screenshot continuity break near y=${partialHorizontal.y}, running from x=${partialHorizontal.start} to x=${partialHorizontal.end}`);
3058
3201
  }
3059
3202
  }
3060
3203
 
@@ -3106,7 +3249,7 @@ export class OpenClawCLI extends EventEmitter {
3106
3249
  }
3107
3250
 
3108
3251
  if (partialVertical) {
3109
- warnings.push(`large partial-surface hard-edged background/tint block edge near x=${partialVertical.x}, running from y=${partialVertical.start} to y=${partialVertical.end}. This often indicates an unintended rectangular overlay, cropped layer, or mismatched background plane.`);
3252
+ warnings.push(`visible partial-surface screenshot continuity break near x=${partialVertical.x}, running from y=${partialVertical.start} to y=${partialVertical.end}`);
3110
3253
  }
3111
3254
  }
3112
3255
  return warnings;
@@ -3554,7 +3697,7 @@ export class OpenClawCLI extends EventEmitter {
3554
3697
  try { return new URL(summaryUrl || requestedUrl || 'http://invalid.local/').pathname || ''; } catch { return ''; }
3555
3698
  })();
3556
3699
  const isAssetBrowserTarget = /\.(?:png|jpe?g|gif|webp|svg|ico|css|js|mjs|json|map|pdf|txt|xml|woff2?|ttf|otf)(?:$|[?#])/i.test(browserPathText);
3557
- const matchedScopedSlugs = explicitScopeSlugs.filter(slug => browserScopeUrlText.includes(slug));
3700
+ const matchedScopedSlugs = scopeSlugsMatchingText(browserScopeUrlText, explicitScopeSlugs);
3558
3701
  const isScopedBrowserTarget =
3559
3702
  explicitScopeSlugs.length === 0 ||
3560
3703
  matchedScopedSlugs.length > 0;
@@ -3707,7 +3850,7 @@ export class OpenClawCLI extends EventEmitter {
3707
3850
  ? `${visualWarningPrefix}: ${summary.brokenImages.length} visible image(s) failed to load: ${summary.brokenImages.map(img => img.alt || img.src || '(unlabeled image)').join(', ')}. ${visualWarningTail}`
3708
3851
  : '';
3709
3852
  const screenshotDiscontinuityWarning = screenshotDiscontinuityWarnings.length > 0
3710
- ? `${visualWarningPrefix}: screenshot shows ${screenshotDiscontinuityWarnings.slice(0, 2).join('; ')}. Rework the affected layout/background/layering so the changed screen reads as intentional, then verify the page again. ${visualWarningTail}`
3853
+ ? `${visualWarningPrefix}: screenshot pixel scan flagged visible continuity issue(s): ${screenshotDiscontinuityWarnings.slice(0, 2).join('; ')}. Inspect the screenshot and source to identify the actual rendering cause, then rework the affected layout/background/layering so the changed screen reads as intentional. Verify the page again. ${visualWarningTail}`
3711
3854
  : '';
3712
3855
  const reportLowContrastControls = options?.afterMutation && isScopedBrowserTarget && explicitScopeSlugs.length > 0
3713
3856
  ? (summary.lowContrastControls || []).filter(c => !c.chrome)
@@ -4091,8 +4234,7 @@ export class OpenClawCLI extends EventEmitter {
4091
4234
  return resolveDirectToolPath(args?.path || args?.file_path);
4092
4235
  };
4093
4236
  const scopeSlugsInText = (value) => {
4094
- const text = String(value || '').toLowerCase();
4095
- return explicitScopeSlugsForTask.filter(slug => text.includes(slug));
4237
+ return scopeSlugsMatchingText(value, explicitScopeSlugsForTask);
4096
4238
  };
4097
4239
  const uiSourcePathInfo = (inputPath) => {
4098
4240
  const candidate = String(inputPath || '').trim();
@@ -4160,20 +4302,20 @@ export class OpenClawCLI extends EventEmitter {
4160
4302
  const candidates = comparableSiblingSourceCandidates(fileWriteTarget);
4161
4303
  return candidates.length >= 2 ? 2 : 1;
4162
4304
  };
4163
- const comparableUiContextCountForMutation = (fileWriteTarget = null) => {
4305
+ const comparableSourceContextCountForMutation = (fileWriteTarget = null) => {
4164
4306
  rememberTargetUiSource(fileWriteTarget);
4165
4307
  const dirsWithSiblings = [...directTargetUiSourceDirs].filter(hasComparableSiblingSource);
4166
4308
  if (dirsWithSiblings.length > 0) {
4167
4309
  const matching = directReferenceInspections.filter(item => {
4168
4310
  if (item.kind === 'source' && dirsWithSiblings.includes(item.dir)) return true;
4169
- if (item.kind === 'browser' && item.urlPathDir) {
4170
- return dirsWithSiblings.some(dir => dir.replace(/\\/g, '/').endsWith(item.urlPathDir));
4171
- }
4172
4311
  return false;
4173
4312
  });
4174
- return new Set(matching.map(item => `${item.kind}:${item.value}`)).size;
4313
+ return new Set(matching.map(item => item.value)).size;
4175
4314
  }
4176
- return new Set(directReferenceInspections.map(item => `${item.kind}:${item.value}`)).size;
4315
+ return new Set(directReferenceInspections.filter(item => item.kind === 'source').map(item => item.value)).size;
4316
+ };
4317
+ const comparableUiContextCountForMutation = (fileWriteTarget = null) => {
4318
+ return comparableSourceContextCountForMutation(fileWriteTarget);
4177
4319
  };
4178
4320
  const comparableVisualContextCountForMutation = (fileWriteTarget = null) => {
4179
4321
  rememberTargetUiSource(fileWriteTarget);
@@ -4185,11 +4327,39 @@ export class OpenClawCLI extends EventEmitter {
4185
4327
  });
4186
4328
  return new Set(matching.map(item => item.value)).size;
4187
4329
  };
4330
+ const localBrowserContextUrls = () => {
4331
+ const urls = [...directLocalScopedUrls];
4332
+ for (const item of directReferenceInspections) {
4333
+ if (item.kind === 'browser' && isLocalUiUrl(item.value) && !urls.includes(item.value)) {
4334
+ urls.push(item.value);
4335
+ }
4336
+ }
4337
+ return urls;
4338
+ };
4339
+ const sourceCandidateUrlPaths = (candidate) => {
4340
+ const rel = String(candidate || '').replace(/\\/g, '/').replace(/^\/+/, '');
4341
+ const paths = [];
4342
+ const add = (value) => {
4343
+ const normalized = `/${String(value || '').replace(/^\/+/, '')}`;
4344
+ if (!paths.includes(normalized)) paths.push(normalized);
4345
+ };
4346
+ for (const root of ['public_html', 'public', 'static', 'dist', 'build']) {
4347
+ if (rel === root || rel.startsWith(`${root}/`)) {
4348
+ add(rel.slice(root.length).replace(/^\/+/, ''));
4349
+ }
4350
+ const marker = `/${root}/`;
4351
+ const idx = rel.indexOf(marker);
4352
+ if (idx >= 0) add(rel.slice(idx + marker.length));
4353
+ }
4354
+ add(path.basename(rel));
4355
+ return paths;
4356
+ };
4188
4357
  const comparableBrowserUrlCandidates = (fileWriteTarget = null) => {
4189
4358
  const sourceCandidates = comparableSiblingSourceCandidates(fileWriteTarget);
4190
- if (!sourceCandidates.length || directLocalScopedUrls.length === 0) return [];
4359
+ const baseUrls = localBrowserContextUrls();
4360
+ if (!sourceCandidates.length || baseUrls.length === 0) return [];
4191
4361
  const urls = [];
4192
- for (const knownUrl of [...directLocalScopedUrls].reverse()) {
4362
+ for (const knownUrl of [...baseUrls].reverse()) {
4193
4363
  let parsed = null;
4194
4364
  try { parsed = new URL(knownUrl); } catch { parsed = null; }
4195
4365
  if (!parsed) continue;
@@ -4197,18 +4367,27 @@ export class OpenClawCLI extends EventEmitter {
4197
4367
  const basePath = slash >= 0 ? parsed.pathname.slice(0, slash + 1) : '/';
4198
4368
  for (const candidate of sourceCandidates) {
4199
4369
  parsed.pathname = `${basePath}${path.basename(candidate)}`;
4200
- const next = parsed.href;
4201
- if (!urls.includes(next)) urls.push(next);
4370
+ parsed.search = '';
4371
+ parsed.hash = '';
4372
+ const sameDirUrl = parsed.href;
4373
+ if (!urls.includes(sameDirUrl)) urls.push(sameDirUrl);
4374
+ for (const urlPath of sourceCandidateUrlPaths(candidate)) {
4375
+ parsed.pathname = urlPath;
4376
+ parsed.search = '';
4377
+ parsed.hash = '';
4378
+ const rootMappedUrl = parsed.href;
4379
+ if (!urls.includes(rootMappedUrl)) urls.push(rootMappedUrl);
4380
+ }
4202
4381
  if (urls.length >= 4) return urls;
4203
4382
  }
4204
4383
  }
4205
4384
  return urls;
4206
4385
  };
4207
4386
  const requiresComparableVisualContextForMutation = (fileWriteTarget = null) => {
4208
- return directLocalScopedUrls.length > 0 && comparableSiblingSourceCandidates(fileWriteTarget).length > 0;
4387
+ return localBrowserContextUrls().length > 0 && comparableSiblingSourceCandidates(fileWriteTarget).length > 0;
4209
4388
  };
4210
4389
  const hasComparableUiContextForMutation = (fileWriteTarget = null) => {
4211
- const hasSourceContext = comparableUiContextCountForMutation(fileWriteTarget) >= requiredComparableUiContextCount(fileWriteTarget);
4390
+ const hasSourceContext = comparableSourceContextCountForMutation(fileWriteTarget) >= requiredComparableUiContextCount(fileWriteTarget);
4212
4391
  if (!hasSourceContext) return false;
4213
4392
  if (!requiresComparableVisualContextForMutation(fileWriteTarget)) return true;
4214
4393
  return comparableVisualContextCountForMutation(fileWriteTarget) >= 1;
@@ -4246,17 +4425,18 @@ export class OpenClawCLI extends EventEmitter {
4246
4425
  const missingComparableUiContextMessage = (fileWriteTarget = null) => {
4247
4426
  const siblingCandidates = comparableSiblingSourceCandidates(fileWriteTarget);
4248
4427
  const requiredCount = requiredComparableUiContextCount(fileWriteTarget);
4249
- const currentCount = comparableUiContextCountForMutation(fileWriteTarget);
4428
+ const currentSourceCount = comparableSourceContextCountForMutation(fileWriteTarget);
4429
+ const currentVisualCount = comparableVisualContextCountForMutation(fileWriteTarget);
4250
4430
  const candidateText = siblingCandidates.length > 0
4251
4431
  ? ` Read ${requiredCount === 1 ? 'one nearby peer source file' : 'at least two nearby peer source files'} first, for example: ${siblingCandidates.join(', ')}.`
4252
4432
  : '';
4253
4433
  const browserCandidates = comparableBrowserUrlCandidates(fileWriteTarget);
4254
4434
  const needsVisualContext = requiresComparableVisualContextForMutation(fileWriteTarget);
4255
4435
  const visualContextText = needsVisualContext
4256
- ? ` Also open at least one nearby peer page from the same app/site and inspect it visually before editing. Current comparable visual context: ${comparableVisualContextCountForMutation(fileWriteTarget)}/1.${browserCandidates.length > 0 ? ` Example local peer URL(s): ${browserCandidates.join(', ')}.` : ''}`
4436
+ ? ` Open at least one nearby peer page from the same app/site in the browser and inspect it visually before editing. Current comparable visual context: ${currentVisualCount}/1.${browserCandidates.length > 0 ? ` Example local peer URL(s): ${browserCandidates.join(', ')}.` : ''}`
4257
4437
  : '';
4258
4438
  return [
4259
- `Before editing scoped UI pages, inspect ${requiredCount === 1 ? 'at least one comparable local UI surface' : 'at least two comparable local UI surfaces'} from this project. Current comparable page-level context: ${currentCount}/${requiredCount}.`,
4439
+ `Before editing scoped UI pages, inspect ${requiredCount === 1 ? 'at least one comparable local UI surface' : 'at least two comparable local UI surfaces'} from this project. Current comparable source context: ${currentSourceCount}/${requiredCount}.`,
4260
4440
  'When comparable sibling pages/components exist near the target source, use one of those as read-only design-system context.',
4261
4441
  'Do not count the site homepage, index/listing pages, header/footer partials, or shared/global CSS as the required page-level comparable context.',
4262
4442
  'Shared/global CSS can supplement peer context, but it is not enough by itself for page-level design work.',
@@ -4304,7 +4484,15 @@ export class OpenClawCLI extends EventEmitter {
4304
4484
  };
4305
4485
  const browserResultUrl = (result) => {
4306
4486
  const match = String(result || '').match(/^URL:\s*(.+)$/mi);
4307
- return match?.[1]?.trim() || '';
4487
+ const url = match?.[1]?.trim() || '';
4488
+ if (!url) return '';
4489
+ try {
4490
+ const parsed = new URL(url);
4491
+ parsed.searchParams.delete('__agentforge_verify');
4492
+ return parsed.href;
4493
+ } catch {
4494
+ return url.replace(/([?&])__agentforge_verify=[^&#]+&?/, '$1').replace(/[?&]$/, '');
4495
+ }
4308
4496
  };
4309
4497
  const isLocalUiUrl = (url) =>
4310
4498
  /^(?:https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\])(?::\d+)?(?:\/|$)|file:)/i.test(String(url || ''));
@@ -4326,6 +4514,15 @@ export class OpenClawCLI extends EventEmitter {
4326
4514
  if (directLocalScopedUrls.length > 8) directLocalScopedUrls.shift();
4327
4515
  persistDirectUiContext();
4328
4516
  };
4517
+ const forgetLocalScopedUrl = (slug, url = '') => {
4518
+ if (slug && directLocalUrlsByScope.get(slug) === url) directLocalUrlsByScope.delete(slug);
4519
+ if (url) {
4520
+ for (let i = directLocalScopedUrls.length - 1; i >= 0; i--) {
4521
+ if (directLocalScopedUrls[i] === url) directLocalScopedUrls.splice(i, 1);
4522
+ }
4523
+ }
4524
+ persistDirectUiContext();
4525
+ };
4329
4526
  const missingScopedVerificationSlugs = () =>
4330
4527
  [...directScopeLastMutationCounts.keys()]
4331
4528
  .filter(slug => (directScopeLastVerificationCounts.get(slug) || 0) < (directScopeLastMutationCounts.get(slug) || 0));
@@ -4425,6 +4622,9 @@ export class OpenClawCLI extends EventEmitter {
4425
4622
  directBrowserUsed = true;
4426
4623
  const visualWarning = extractVisualWarning(result);
4427
4624
  if (visualWarning) {
4625
+ if (/rendered page content did not identify target|local server returned a fallback\/index page|Browser failed to load/i.test(visualWarning)) {
4626
+ forgetLocalScopedUrl(slug, url);
4627
+ }
4428
4628
  visualWarnings.push(`${slug}: ${visualWarning}`);
4429
4629
  recordDirectToolSummary('browser', result);
4430
4630
  continue;
@@ -4450,7 +4650,7 @@ export class OpenClawCLI extends EventEmitter {
4450
4650
  const urlText = match?.[1]?.trim() || '';
4451
4651
  if (!urlText) return false;
4452
4652
  const { slugs } = this._extractDirectExplicitScope(task);
4453
- if (slugs.length > 0 && !slugs.some(slug => urlText.toLowerCase().includes(slug))) return false;
4653
+ if (slugs.length > 0 && scopeSlugsMatchingText(urlText, slugs).length === 0) return false;
4454
4654
  return /^(?:https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\])(?::\d+)?(?:\/|$)|file:)/i.test(urlText);
4455
4655
  };
4456
4656
  const extractVisualWarning = (result) => {
@@ -4809,7 +5009,7 @@ export class OpenClawCLI extends EventEmitter {
4809
5009
  const message = missingComparableUiContextMessage(fileWriteTarget);
4810
5010
  directComparableContextBlockCount += 1;
4811
5011
  comparableContextBlockThisBatch = message;
4812
- emitDirectThought('AgentForge blocked the first scoped UI edit because comparable project UI context has not been inspected yet.');
5012
+ emitDirectThought('AgentForge blocked a scoped UI edit because comparable project UI context has not been inspected yet.');
4813
5013
  this.emit('tool_activity', {
4814
5014
  agentId,
4815
5015
  event: 'tool_error',
@@ -4884,7 +5084,14 @@ export class OpenClawCLI extends EventEmitter {
4884
5084
  if (name === 'browser') {
4885
5085
  directBrowserUsed = true;
4886
5086
  const visualWarning = extractVisualWarning(result);
4887
- if (!visualWarning) rememberLocalScopedUrl(browserResultUrl(result));
5087
+ const resultUrl = browserResultUrl(result);
5088
+ const invalidScopedLoad = /rendered page content did not identify target|local server returned a fallback\/index page|Browser failed to load/i.test(visualWarning || '');
5089
+ if (!invalidScopedLoad) rememberLocalScopedUrl(resultUrl || args?.url || '');
5090
+ else {
5091
+ for (const slug of scopeSlugsInText(`${resultUrl}\n${args?.url || ''}`)) {
5092
+ forgetLocalScopedUrl(slug, resultUrl || args?.url || '');
5093
+ }
5094
+ }
4888
5095
  if (directMutationCount > 0) {
4889
5096
  if (visualWarning) {
4890
5097
  lastDirectVisualWarning = visualWarning;
@@ -4931,7 +5138,7 @@ export class OpenClawCLI extends EventEmitter {
4931
5138
  text: [
4932
5139
  'The previous write was rejected because it was outside the requested task scope.',
4933
5140
  scopeViolationThisBatch,
4934
- 'Do not retry shared, global, or reference files. Implement the required UI changes inside the named target files or their directly owned assets only. If styling is needed, use page-local styles in those target files.',
5141
+ 'Do not retry shared, global, or reference files. Implement the required UI changes inside the named target files or their directly owned assets only. If styling is needed, use page-local styles in those target files or page-owned scoped stylesheets whose names clearly match the target page.',
4935
5142
  ].join(' '),
4936
5143
  }],
4937
5144
  });
@@ -4940,9 +5147,9 @@ export class OpenClawCLI extends EventEmitter {
4940
5147
  if (comparableContextBlockThisBatch) {
4941
5148
  if (directComparableContextBlockCount >= 3) {
4942
5149
  return directStopResponse([
4943
- 'The model repeatedly tried to edit scoped UI before inspecting a non-target peer UI source.',
5150
+ 'The model repeatedly tried to edit scoped UI before inspecting non-target peer UI context.',
4944
5151
  comparableContextBlockThisBatch,
4945
- 'This is not complete. Continue in a fresh iteration by reading one non-target sibling page/component first, then edit only the requested target files.',
5152
+ 'This is not complete. Continue in a fresh iteration by reading non-target sibling page/component source and opening a nearby peer page in the browser when a local preview is available, then edit only the requested target files.',
4946
5153
  ].join(' '), true);
4947
5154
  }
4948
5155
  contents.push({
@@ -4951,7 +5158,7 @@ export class OpenClawCLI extends EventEmitter {
4951
5158
  text: [
4952
5159
  'The previous edit was rejected because comparable project UI context has not been inspected yet.',
4953
5160
  comparableContextBlockThisBatch,
4954
- 'Reading another requested target page does not satisfy this requirement. Read one non-target sibling page/component source from the candidate list first, then edit only the requested target files.',
5161
+ 'Reading another requested target page does not satisfy this requirement. Read non-target sibling page/component source from the candidate list and, when a local peer URL is provided, open that peer page in the browser before editing only the requested target files.',
4955
5162
  ].join(' '),
4956
5163
  }],
4957
5164
  });
package/src/browser.js CHANGED
@@ -54,6 +54,47 @@ async function getPage(agentId, preferUrl) {
54
54
 
55
55
  const _wait = (ms) => new Promise(r => setTimeout(r, ms));
56
56
 
57
+ function isLocalHttpUrl(value) {
58
+ return /^https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\])(?::\d+)?(?:\/|$)/i.test(String(value || ''));
59
+ }
60
+
61
+ function withLocalCacheBust(value) {
62
+ if (!isLocalHttpUrl(value)) return value;
63
+ try {
64
+ const parsed = new URL(value);
65
+ parsed.searchParams.set('__agentforge_verify', `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
66
+ return parsed.href;
67
+ } catch {
68
+ const sep = String(value).includes('?') ? '&' : '?';
69
+ return `${value}${sep}__agentforge_verify=${Date.now()}`;
70
+ }
71
+ }
72
+
73
+ async function waitForPageReady(page, expectedUrl) {
74
+ const stripCacheBust = (value) => {
75
+ try {
76
+ const parsed = new URL(value);
77
+ parsed.searchParams.delete('__agentforge_verify');
78
+ return parsed.href.replace(/\/$/, '');
79
+ } catch {
80
+ return String(value || '').replace(/([?&])__agentforge_verify=[^&#]+&?/, '$1').replace(/[?&]$/, '').replace(/\/$/, '');
81
+ }
82
+ };
83
+ const expected = expectedUrl ? stripCacheBust(expectedUrl) : '';
84
+ for (let i = 0; i < 45; i++) {
85
+ try {
86
+ const state = await page.evaluate(() => ({
87
+ href: location.href,
88
+ readyState: document.readyState,
89
+ }));
90
+ if ((!expected || stripCacheBust(state.href) === expected) && state.readyState !== 'loading') return;
91
+ } catch {
92
+ // Page contexts can disappear while navigation commits.
93
+ }
94
+ await _wait(200);
95
+ }
96
+ }
97
+
57
98
  // Take a structured snapshot of a page — content + interactive elements + all inputs
58
99
  async function _snapshot(page, agentId) {
59
100
  const url = page.url();
@@ -160,9 +201,11 @@ async function _browserActionInner(input, agentId, browser) {
160
201
  case 'open': {
161
202
  const url = input.url || input.targetUrl;
162
203
  const page = await getPage(agentId);
204
+ await page.setCacheEnabled(false).catch(() => null);
205
+ const navigationUrl = withLocalCacheBust(url);
163
206
  // Use Promise.race to enforce a hard 20s cap — page.goto timeout option can hang
164
207
  const navResult = await Promise.race([
165
- page.goto(url, { waitUntil: 'domcontentloaded', timeout: 20000 }),
208
+ page.goto(navigationUrl, { waitUntil: 'domcontentloaded', timeout: 20000 }),
166
209
  new Promise((_, rej) => setTimeout(() => rej(new Error(`Navigation hard-timeout (20s): ${url}`)), 20000)),
167
210
  ]).catch(err => ({ __navError: err.message }));
168
211
  if (navResult && navResult.__navError) {
@@ -171,6 +214,7 @@ async function _browserActionInner(input, agentId, browser) {
171
214
  }
172
215
  _agentPages.set(agentId, page);
173
216
  // Brief pause for initial render, then snapshot
217
+ await waitForPageReady(page, url);
174
218
  await _wait(400);
175
219
  return await _snapshot(page, agentId);
176
220
  }
@@ -225,6 +225,11 @@ const server = createServer(async (req, res) => {
225
225
  }
226
226
 
227
227
  if (!existsSync(filePath)) {
228
+ if (extname(urlPath)) {
229
+ res.writeHead(404, { 'Cache-Control': 'no-store, no-cache, must-revalidate, proxy-revalidate' });
230
+ res.end('Not found');
231
+ return;
232
+ }
228
233
  // SPA fallback
229
234
  const fallback = join(serveDir, 'index.html');
230
235
  if (existsSync(fallback)) filePath = fallback;
@@ -245,7 +250,12 @@ const server = createServer(async (req, res) => {
245
250
  content = html;
246
251
  }
247
252
 
248
- res.writeHead(200, { 'Content-Type': mime, 'Cache-Control': 'no-cache' });
253
+ res.writeHead(200, {
254
+ 'Content-Type': mime,
255
+ 'Cache-Control': 'no-store, no-cache, must-revalidate, proxy-revalidate',
256
+ 'Pragma': 'no-cache',
257
+ 'Expires': '0',
258
+ });
249
259
  res.end(content);
250
260
  } catch (e) {
251
261
  res.writeHead(500); res.end('Server error');
@@ -1,6 +1,9 @@
1
1
  const PAGE_KIND_RE = /\b(?:pages?|screens?|routes?|views?|listings?)\b/i;
2
2
  const PAGE_KIND_WORD_RE = /^(?:listing|listings|page|pages|screen|screens|route|routes|view|views|site|website|app)$/i;
3
3
  const ACTION_WORD_RE = /^(?:add|build|change|create|edit|fix|implement|improve|make|redesign|update|work)$/i;
4
+ const COMMON_DOMAIN_SUFFIXES = new Set([
5
+ 'ai', 'app', 'co', 'com', 'dev', 'gg', 'io', 'net', 'org', 'so', 'xyz',
6
+ ]);
4
7
 
5
8
  const GENERIC_SCOPE_WORDS = new Set([
6
9
  'a', 'an', 'the', 'and', 'or', 'page', 'pages', 'screen', 'screens',
@@ -28,6 +31,31 @@ export function scopeSlug(value) {
28
31
  .replace(/^-+|-+$/g, '');
29
32
  }
30
33
 
34
+ export function scopeSlugAliases(value) {
35
+ const slug = scopeSlug(value);
36
+ if (!slug) return [];
37
+
38
+ const aliases = new Set([slug]);
39
+ const parts = slug.split('-').filter(Boolean);
40
+ const suffix = parts.at(-1);
41
+ if (parts.length > 1 && COMMON_DOMAIN_SUFFIXES.has(suffix)) {
42
+ const withoutSuffix = parts.slice(0, -1).join('-');
43
+ if (withoutSuffix.length >= 3 && !GENERIC_SCOPE_WORDS.has(withoutSuffix)) {
44
+ aliases.add(withoutSuffix);
45
+ const compact = withoutSuffix.replace(/-/g, '');
46
+ if (compact.length >= 4 && !GENERIC_SCOPE_WORDS.has(compact)) aliases.add(compact);
47
+ }
48
+ }
49
+
50
+ return [...aliases];
51
+ }
52
+
53
+ export function scopeSlugsMatchingText(value, slugs) {
54
+ const text = String(value || '').toLowerCase();
55
+ return (Array.isArray(slugs) ? slugs : [])
56
+ .filter(slug => scopeSlugAliases(slug).some(alias => alias && text.includes(alias)));
57
+ }
58
+
31
59
  export function scopeExtractionText(message) {
32
60
  const raw = String(message || '');
33
61
  const taskMatches = [...raw.matchAll(/\b(?:The task is|Continue with the task):\s*"([^"]{1,4000})"/gi)];
@@ -116,11 +144,18 @@ export function extractExplicitScopeSlugs(message) {
116
144
  }
117
145
 
118
146
  const candidates = new Set();
147
+ const namedPageScopeSlugs = extractNamedPageScopeSlugs(text);
148
+ const pageContainerSlugs = new Set();
119
149
  for (const match of text.matchAll(/\b[a-z0-9]+(?:[.-][a-z0-9]+)+\b/gi)) {
120
- if (followedByNamedPageTargets(text, match)) continue;
150
+ if (followedByNamedPageTargets(text, match)) {
151
+ const slug = scopeSlug(match[0]);
152
+ if (!namedPageScopeSlugs.includes(slug)) pageContainerSlugs.add(slug);
153
+ continue;
154
+ }
155
+ if (pageContainerSlugs.has(scopeSlug(match[0]))) continue;
121
156
  addScopeCandidate(candidates, match[0]);
122
157
  }
123
- for (const slug of extractNamedPageScopeSlugs(text)) {
158
+ for (const slug of namedPageScopeSlugs) {
124
159
  candidates.add(slug);
125
160
  }
126
161
  return [...candidates];
package/src/worker.js CHANGED
@@ -9,6 +9,7 @@ import {
9
9
  extractExplicitScopeSlugs,
10
10
  extractNamedPageScopeSlugs,
11
11
  hasExplicitScopeRestriction,
12
+ scopeSlugsMatchingText,
12
13
  scopeSlug,
13
14
  } from './taskSemantics.js';
14
15
 
@@ -606,6 +607,10 @@ export class AgentForgeWorker extends EventEmitter {
606
607
  return scopeSlug(value);
607
608
  }
608
609
 
610
+ _scopeSlugsMatchingText(value, slugs) {
611
+ return scopeSlugsMatchingText(value, slugs);
612
+ }
613
+
609
614
  _addScopeCandidate(candidates, value) {
610
615
  return addScopeCandidate(candidates, value);
611
616
  }
@@ -626,6 +631,74 @@ export class AgentForgeWorker extends EventEmitter {
626
631
  return /\.(html?|jsx?|tsx?|vue|svelte|astro|mdx?|css|scss|sass|less)$/i.test(String(relativePath || ''));
627
632
  }
628
633
 
634
+ _isPageAssetPath(relativePath) {
635
+ return /\.(css|scss|sass|less|jsx?|tsx?)$/i.test(String(relativePath || ''));
636
+ }
637
+
638
+ _fileMentionsAsset(content, sourceRel, assetRel) {
639
+ const source = String(content || '');
640
+ const assetUnix = String(assetRel || '').replace(/\\/g, '/');
641
+ const assetBase = path.basename(assetUnix);
642
+ const relativeFromSource = path.relative(path.dirname(sourceRel || ''), assetRel || '').replace(/\\/g, '/');
643
+ const candidates = [
644
+ assetBase,
645
+ assetUnix,
646
+ `/${assetUnix}`,
647
+ relativeFromSource,
648
+ relativeFromSource && !relativeFromSource.startsWith('.') ? `./${relativeFromSource}` : '',
649
+ ].filter(Boolean);
650
+ return candidates.some(candidate => source.includes(candidate));
651
+ }
652
+
653
+ _allCurrentPageSourceFiles(repo) {
654
+ const names = new Set();
655
+ const add = (output) => {
656
+ for (const rel of String(output || '').split('\n').map(line => line.trim()).filter(Boolean)) {
657
+ if (this._isPageSourcePath(rel)) names.add(rel);
658
+ }
659
+ };
660
+ add(this._gitOutput(repo, ['ls-files'], 30000));
661
+ add(this._gitOutput(repo, ['ls-files', '--others', '--exclude-standard'], 30000));
662
+ add(this._parseGitStatusPaths(this._gitStatusPorcelain(repo, 10000)).join('\n'));
663
+ return [...names].sort();
664
+ }
665
+
666
+ _isNewScopedPageOwnedAsset(baseline, rel, allowedSlugs, pageOnly) {
667
+ if (!baseline?.root || !pageOnly || !Array.isArray(allowedSlugs) || allowedSlugs.length === 0) return false;
668
+ if (!this._isPageAssetPath(rel)) return false;
669
+ if (this._scopeSlugsMatchingText(String(rel || '').toLowerCase(), allowedSlugs).length > 0) return true;
670
+ if (this._gitPathExistsAtRef(baseline.root, baseline.head || 'HEAD', rel)) return false;
671
+
672
+ const sourceFiles = this._allCurrentPageSourceFiles(baseline.root).filter(file => file !== rel);
673
+ const scopedSources = [];
674
+ const outOfScopeMentions = [];
675
+
676
+ for (const sourceRel of sourceFiles) {
677
+ const fullPath = path.join(baseline.root, sourceRel);
678
+ let content = '';
679
+ try {
680
+ content = readFileSync(fullPath, 'utf-8');
681
+ } catch {
682
+ continue;
683
+ }
684
+ if (!this._fileMentionsAsset(content, sourceRel, rel)) continue;
685
+ if (this._scopeSlugsMatchingText(String(sourceRel || '').toLowerCase(), allowedSlugs).length > 0) {
686
+ scopedSources.push(sourceRel);
687
+ } else {
688
+ outOfScopeMentions.push(sourceRel);
689
+ }
690
+ }
691
+
692
+ return scopedSources.length > 0 && outOfScopeMentions.length === 0;
693
+ }
694
+
695
+ _scopeAllowsChangedPath(baseline, rel, allowedSlugs, pageOnly) {
696
+ const lower = String(rel || '').toLowerCase();
697
+ const slugAllowed = this._scopeSlugsMatchingText(lower, allowedSlugs).length > 0;
698
+ if (slugAllowed) return !pageOnly || this._isPageSourcePath(rel);
699
+ return this._isNewScopedPageOwnedAsset(baseline, rel, allowedSlugs, pageOnly);
700
+ }
701
+
629
702
  _findScopeDriftRepoChanges(repoBaselines, userMessage) {
630
703
  if (!Array.isArray(repoBaselines) || repoBaselines.length === 0) return [];
631
704
  const { slugs: allowedSlugs, pageOnly } = this._extractExplicitScope(userMessage);
@@ -652,12 +725,7 @@ export class AgentForgeWorker extends EventEmitter {
652
725
  const initialDirty = new Set(baseline.initialDirtyPaths || []);
653
726
  const outOfScope = [...names]
654
727
  .filter(rel => !initialDirty.has(rel))
655
- .filter(rel => {
656
- const lower = rel.toLowerCase();
657
- const slugAllowed = allowedSlugs.some(slug => lower.includes(slug));
658
- if (!slugAllowed) return true;
659
- return pageOnly && !this._isPageSourcePath(rel);
660
- })
728
+ .filter(rel => !this._scopeAllowsChangedPath(baseline, rel, allowedSlugs, pageOnly))
661
729
  .sort();
662
730
  if (outOfScope.length > 0) {
663
731
  warnings.push({ repo: baseline.root, head: baseline.head, allowedSlugs, pageOnly, files: outOfScope });
@@ -723,11 +791,7 @@ export class AgentForgeWorker extends EventEmitter {
723
791
  const initialDirty = new Set(baseline.initialDirtyPaths || []);
724
792
  const files = [...names]
725
793
  .filter(rel => resetPreexistingScopedTargets || !initialDirty.has(rel))
726
- .filter(rel => {
727
- const lower = rel.toLowerCase();
728
- if (!allowedSlugs.some(slug => lower.includes(slug))) return false;
729
- return !pageOnly || this._isPageSourcePath(rel);
730
- })
794
+ .filter(rel => this._scopeAllowsChangedPath(baseline, rel, allowedSlugs, pageOnly))
731
795
  .sort();
732
796
  if (files.length === 0) continue;
733
797
 
@@ -799,7 +863,7 @@ export class AgentForgeWorker extends EventEmitter {
799
863
  const pathName = rel.includes(' -> ') ? rel.split(' -> ').pop() : rel;
800
864
  const lower = String(pathName || '').toLowerCase();
801
865
  if (!this._isPageSourcePath(lower)) continue;
802
- if (!allowedSlugs.some(slug => lower.includes(slug))) continue;
866
+ if (this._scopeSlugsMatchingText(lower, allowedSlugs).length === 0) continue;
803
867
  deleted.push(pathName);
804
868
  }
805
869
  if (deleted.length > 0) {
@@ -997,7 +1061,7 @@ export class AgentForgeWorker extends EventEmitter {
997
1061
  }
998
1062
  if (!currentFile || !uiFileRe.test(currentFile)) continue;
999
1063
  const lowerPath = currentFile.toLowerCase();
1000
- if (allowedSlugs.length > 0 && !allowedSlugs.some(slug => lowerPath.includes(slug))) continue;
1064
+ if (allowedSlugs.length > 0 && this._scopeSlugsMatchingText(lowerPath, allowedSlugs).length === 0) continue;
1001
1065
  if (!/^[+-][^+-]/.test(line)) continue;
1002
1066
  const raw = line.slice(1).trim();
1003
1067
  if (!raw || /^(?:\/\*|\*|\/\/|<!--|-->|})$/.test(raw)) continue;
@@ -1178,7 +1242,7 @@ export class AgentForgeWorker extends EventEmitter {
1178
1242
  }
1179
1243
  if (!currentFile || !uiFileRe.test(currentFile)) continue;
1180
1244
  const lower = currentFile.toLowerCase();
1181
- if (allowedSlugs.length > 0 && !allowedSlugs.some(slug => lower.includes(slug))) continue;
1245
+ if (allowedSlugs.length > 0 && this._scopeSlugsMatchingText(lower, allowedSlugs).length === 0) continue;
1182
1246
  const addedLine = line.slice(1);
1183
1247
  const newSourceComment = (() => {
1184
1248
  commentTextRe.lastIndex = 0;
@@ -1289,7 +1353,7 @@ export class AgentForgeWorker extends EventEmitter {
1289
1353
  for (const filePath of changedFiles) {
1290
1354
  const lower = String(filePath || '').toLowerCase();
1291
1355
  for (const slug of allowedSlugs) {
1292
- if (lower.includes(slug)) touchedSlugs.add(slug);
1356
+ if (this._scopeSlugsMatchingText(lower, [slug]).length > 0) touchedSlugs.add(slug);
1293
1357
  }
1294
1358
  }
1295
1359