@hamp10/agentforge 0.2.25 → 0.2.27

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.25",
3
+ "version": "0.2.27",
4
4
  "description": "AgentForge worker — connect your machine to agentforge.ai",
5
5
  "type": "module",
6
6
  "bin": {
@@ -668,6 +668,7 @@ try {
668
668
  const openClawSource = readFileSync(new URL('../src/OpenClawCLI.js', import.meta.url), 'utf-8');
669
669
  const workerSource = readFileSync(new URL('../src/worker.js', import.meta.url), 'utf-8');
670
670
  const workerBinSource = readFileSync(new URL('../bin/agentforge.js', import.meta.url), 'utf-8');
671
+ const selfUpdateSource = readFileSync(new URL('../src/selfUpdate.js', import.meta.url), 'utf-8');
671
672
  const defaultGuidesSource = readFileSync(new URL('../src/default-task-guides.js', import.meta.url), 'utf-8');
672
673
  const browserSource = readFileSync(new URL('../src/browser.js', import.meta.url), 'utf-8');
673
674
  const previewServerSource = readFileSync(new URL('../src/preview-server.js', import.meta.url), 'utf-8');
@@ -838,16 +839,41 @@ assert.match(
838
839
  /app\.delete\('\/api\/agents\/delete\/cleanup-empty'[\s\S]*activeTasks[\s\S]*type: 'agents_pruned'/i,
839
840
  'empty-agent cleanup should preserve live tasks and broadcast removed agents'
840
841
  );
842
+ assert.match(
843
+ serverSource,
844
+ /CREATE TABLE IF NOT EXISTS agent_flows[\s\S]*config JSONB DEFAULT NULL/i,
845
+ 'agent flow emergency schema patch should include config so model routing survives skipped migrations'
846
+ );
847
+ assert.match(
848
+ serverSource,
849
+ /ALTER TABLE agent_flows ADD COLUMN IF NOT EXISTS config JSONB DEFAULT NULL/i,
850
+ 'agent flow startup should self-heal missing config column'
851
+ );
852
+ assert.match(
853
+ serverSource,
854
+ /ON CONFLICT \(id\)[\s\S]*WHERE agent_flows\.user_id = EXCLUDED\.user_id[\s\S]*RETURNING id/i,
855
+ 'flow upserts should not update another user flow with the same id'
856
+ );
841
857
  assert.match(
842
858
  dashboardSource,
843
859
  /confirmDeleteAgent[\s\S]*await fetch[\s\S]*removeAgentFromLocalState/i,
844
860
  'single-agent delete should only remove local UI after the server confirms deletion'
845
861
  );
862
+ assert.match(
863
+ dashboardSource,
864
+ /confirmPruneAgents[\s\S]*\/api\/agents\/delete\/prune\?keep=15[\s\S]*removeAgentFromLocalState/i,
865
+ 'dashboard should expose old-agent pruning through the safe keep-latest endpoint'
866
+ );
846
867
  assert.match(
847
868
  dashboardSource,
848
869
  /function finalizeLiveFeedBeforeGuide[\s\S]*commitChunkBuffer[\s\S]*hideTypingIndicator/i,
849
870
  'live Guide messages should close the current visible agent feed before rendering the user bubble'
850
871
  );
872
+ assert.match(
873
+ selfUpdateSource,
874
+ /ensureWritableGlobalInstallEnv[\s\S]*canWriteNpmRoot[\s\S]*configureUserOwnedNpmGlobalEnv/i,
875
+ 'self-update should preflight npm global permissions before printing a failed system-prefix install'
876
+ );
851
877
  assert.match(
852
878
  dashboardSource,
853
879
  /async function sendGuideMessage[\s\S]*agent\._guideInflight[\s\S]*finalizeLiveFeedBeforeGuide\(agentId\)[\s\S]*addMessage/i,
@@ -918,6 +944,16 @@ assert.match(
918
944
  /__agentforge_verify/i,
919
945
  'direct browser verification should cache-bust local verification URLs'
920
946
  );
947
+ assert.match(
948
+ openClawSource,
949
+ /normalizeForNavigationCompare\(target\?\.url \|\| ''\) === normalizeForNavigationCompare\(effectiveRequestedUrl\)/,
950
+ 'direct browser verification should reuse an existing cache-busted tab for the same requested local URL'
951
+ );
952
+ assert.match(
953
+ openClawSource,
954
+ /isLocalVerificationUrl[\s\S]*__agentforge_verify[\s\S]*closeDuplicateVerificationTargets[\s\S]*\/json\/close\//i,
955
+ 'direct browser verification should close duplicate cache-busted local verification tabs'
956
+ );
921
957
  assert.match(
922
958
  openClawSource,
923
959
  /forgetLocalScopedUrl[\s\S]*rendered page content did not identify target|rendered page content did not identify target[\s\S]*forgetLocalScopedUrl/i,
@@ -933,6 +969,16 @@ assert.match(
933
969
  /__agentforge_verify/i,
934
970
  'AgentForge browser tool should cache-bust localhost navigations'
935
971
  );
972
+ assert.match(
973
+ browserSource,
974
+ /sameBrowserTargetUrl[\s\S]*stripLocalCacheBust[\s\S]*getPage\(agentId, url\)/i,
975
+ 'AgentForge browser tool should reuse cache-busted tabs for the same requested local URL'
976
+ );
977
+ assert.match(
978
+ browserSource,
979
+ /isLocalVerificationUrl[\s\S]*__agentforge_verify[\s\S]*closeStaleVerificationTabs[\s\S]*page\.close/i,
980
+ 'AgentForge browser tool should close stale cache-busted localhost verification tabs'
981
+ );
936
982
  assert.match(
937
983
  previewServerSource,
938
984
  /extname\(urlPath\)[\s\S]*writeHead\(404[\s\S]*no-store/i,
@@ -953,6 +999,16 @@ assert.doesNotMatch(
953
999
  /taskRequiresVisualVerification\s*&&\s*directIteration\s*<=\s*1\s*&&\s*explicitScopeForTask\.pageOnly/s,
954
1000
  'comparable UI context gate should apply on retries, not only first iteration'
955
1001
  );
1002
+ assert.match(
1003
+ openClawSource,
1004
+ /read-only project context, not as a target page/,
1005
+ 'read-only comparable page inspection should not be narrated as target-page work'
1006
+ );
1007
+ assert.match(
1008
+ openClawSource,
1009
+ /verifiedLocalUrl = isLocalUiUrl\(resultUrl \|\| url\)/,
1010
+ 'scoped auto-verification should count a clean inferred local target URL even when browser output formatting is imperfect'
1011
+ );
956
1012
  assert.match(
957
1013
  openClawSource,
958
1014
  /const minRatio = isLargeText \? 3 : 4\.5;/,
@@ -2761,6 +2761,8 @@ export class OpenClawCLI extends EventEmitter {
2761
2761
  const normalizeForNavigationCompare = (value) => stripAgentForgeCacheBust(normalize(value));
2762
2762
  const isLocalHttpUrl = (value) =>
2763
2763
  /^https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\])(?::\d+)?(?:\/|$)/i.test(String(value || ''));
2764
+ const isLocalVerificationUrl = (value) =>
2765
+ isLocalHttpUrl(value) && /[?&]__agentforge_verify=/.test(String(value || ''));
2764
2766
  const withVerificationCacheBust = (value) => {
2765
2767
  if (!value || !isLocalHttpUrl(value)) return value;
2766
2768
  try {
@@ -2790,9 +2792,35 @@ export class OpenClawCLI extends EventEmitter {
2790
2792
  const usablePages = !taskIsAboutAgentForge
2791
2793
  ? pages.filter(p => !isAgentForgeControlSurface(p.url))
2792
2794
  : pages;
2795
+ const matchesEffectiveRequestedUrl = (target) =>
2796
+ !!effectiveRequestedUrl && normalizeForNavigationCompare(target?.url || '') === normalizeForNavigationCompare(effectiveRequestedUrl);
2797
+ const requestedPageMatches = effectiveRequestedUrl
2798
+ ? pages.filter(matchesEffectiveRequestedUrl)
2799
+ : [];
2793
2800
  let page = effectiveRequestedUrl
2794
- ? pages.find(p => normalize(p.url) === normalize(effectiveRequestedUrl))
2801
+ ? requestedPageMatches.find(p => !isLocalVerificationUrl(p.url)) || requestedPageMatches[0] || null
2795
2802
  : usablePages.find(p => p.url && p.url !== 'about:blank') || null;
2803
+ const closeDuplicateVerificationTargets = async (keepTarget) => {
2804
+ if (!effectiveRequestedUrl || !keepTarget) return;
2805
+ const staleTargets = requestedPageMatches.filter(target =>
2806
+ target.id !== keepTarget.id &&
2807
+ isLocalVerificationUrl(target.url) &&
2808
+ normalizeForNavigationCompare(target.url) === normalizeForNavigationCompare(effectiveRequestedUrl)
2809
+ );
2810
+ if (staleTargets.length === 0) return;
2811
+ let closed = 0;
2812
+ await Promise.all(staleTargets.map(async (target) => {
2813
+ try {
2814
+ const closeResp = await fetch(`${cdpBase}/json/close/${encodeURIComponent(target.id)}`, {
2815
+ signal: AbortSignal.timeout(3_000),
2816
+ });
2817
+ if (closeResp.ok) closed += 1;
2818
+ } catch {}
2819
+ }));
2820
+ if (closed > 0) {
2821
+ console.log(` [${agentId}] 🧹 Closed ${closed} stale verification tab(s) for ${stripAgentForgeCacheBust(effectiveRequestedUrl)}`);
2822
+ }
2823
+ };
2796
2824
 
2797
2825
  const controlSurfaceUrl = effectiveRequestedUrl || page?.url || '';
2798
2826
  if (!taskIsAboutAgentForge && isAgentForgeControlSurface(controlSurfaceUrl)) {
@@ -2811,6 +2839,7 @@ export class OpenClawCLI extends EventEmitter {
2811
2839
  if (!newResp.ok) throw new Error(`CDP new page failed: HTTP ${newResp.status}`);
2812
2840
  page = await newResp.json();
2813
2841
  }
2842
+ await closeDuplicateVerificationTargets(page);
2814
2843
 
2815
2844
  const ws = new WebSocket(page.webSocketDebuggerUrl);
2816
2845
  let seq = 0;
@@ -2892,7 +2921,14 @@ export class OpenClawCLI extends EventEmitter {
2892
2921
  }
2893
2922
  return lastState;
2894
2923
  };
2895
- if (effectiveRequestedUrl && normalizeForNavigationCompare(page.url) !== normalizeForNavigationCompare(effectiveRequestedUrl)) {
2924
+ const currentPageMatchesRequest = effectiveRequestedUrl && normalizeForNavigationCompare(page.url) === normalizeForNavigationCompare(effectiveRequestedUrl);
2925
+ const shouldNavigateForFreshVerification = !!(
2926
+ effectiveRequestedUrl &&
2927
+ options?.afterMutation &&
2928
+ navigationRequestedUrl &&
2929
+ navigationRequestedUrl !== effectiveRequestedUrl
2930
+ );
2931
+ if (effectiveRequestedUrl && (!currentPageMatchesRequest || shouldNavigateForFreshVerification)) {
2896
2932
  await cdp('Page.navigate', { url: navigationRequestedUrl || effectiveRequestedUrl });
2897
2933
  await waitForRequestedDocument(effectiveRequestedUrl);
2898
2934
  } else if (effectiveRequestedUrl) {
@@ -4629,8 +4665,10 @@ export class OpenClawCLI extends EventEmitter {
4629
4665
  recordDirectToolSummary('browser', result);
4630
4666
  continue;
4631
4667
  }
4632
- rememberLocalScopedUrl(browserResultUrl(result));
4633
- if (isLocalUiVerificationResult(result)) {
4668
+ const resultUrl = browserResultUrl(result);
4669
+ rememberLocalScopedUrl(resultUrl);
4670
+ const verifiedLocalUrl = isLocalUiUrl(resultUrl || url);
4671
+ if (isLocalUiVerificationResult(result) || verifiedLocalUrl) {
4634
4672
  recordCleanLocalVerification(result, [slug]);
4635
4673
  verifiedAny = true;
4636
4674
  }
@@ -4766,6 +4804,13 @@ export class OpenClawCLI extends EventEmitter {
4766
4804
  return `I am checking the live browser${url} to judge the actual product surface before deciding the next change.`;
4767
4805
  }
4768
4806
  if (name === 'read_file' || name === 'read') {
4807
+ const scope = extractExplicitScope(task);
4808
+ const targetText = String(targetPath || base || '').toLowerCase();
4809
+ const looksLikePageSource = /\.(html?|css|s[ac]ss|jsx?|tsx?|vue|svelte|astro|mdx?)$/i.test(targetText);
4810
+ const isNamedTarget = scope.slugs.length > 0 && scopeSlugsMatchingText(targetText, scope.slugs).length > 0;
4811
+ if (base && looksLikePageSource && scope.slugs.length > 0 && !isNamedTarget) {
4812
+ return `I am reading ${base} as read-only project context, not as a target page.`;
4813
+ }
4769
4814
  return base
4770
4815
  ? `I am reading ${base} to work from the current implementation instead of guessing.`
4771
4816
  : `I am reading the current code before making the next change.`;
@@ -5097,6 +5142,10 @@ export class OpenClawCLI extends EventEmitter {
5097
5142
  lastDirectVisualWarning = visualWarning;
5098
5143
  } else if (!taskRequiresVisualVerification || isLocalUiVerificationResult(result)) {
5099
5144
  recordCleanLocalVerification(result);
5145
+ } else {
5146
+ const verifiedUrl = resultUrl || args?.url || '';
5147
+ const verifiedSlugs = isLocalUiUrl(verifiedUrl) ? scopeSlugsInText(verifiedUrl) : [];
5148
+ if (verifiedSlugs.length > 0) recordCleanLocalVerification(result, verifiedSlugs);
5100
5149
  }
5101
5150
  }
5102
5151
  }
package/src/browser.js CHANGED
@@ -25,6 +25,43 @@ async function getBrowser() {
25
25
  return _browser;
26
26
  }
27
27
 
28
+ function stripLocalCacheBust(value) {
29
+ try {
30
+ const parsed = new URL(value);
31
+ parsed.searchParams.delete('__agentforge_verify');
32
+ return parsed.href.replace(/\/$/, '');
33
+ } catch {
34
+ return String(value || '').replace(/([?&])__agentforge_verify=[^&#]+&?/, '$1').replace(/[?&]$/, '').replace(/\/$/, '');
35
+ }
36
+ }
37
+
38
+ function sameBrowserTargetUrl(left, right) {
39
+ if (!left || !right) return false;
40
+ return stripLocalCacheBust(left) === stripLocalCacheBust(right);
41
+ }
42
+
43
+ function isLocalVerificationUrl(value) {
44
+ return isLocalHttpUrl(value) && /[?&]__agentforge_verify=/.test(String(value || ''));
45
+ }
46
+
47
+ async function closeStaleVerificationTabs(browser, keepPage, targetUrl) {
48
+ const expected = targetUrl ? stripLocalCacheBust(targetUrl) : '';
49
+ const pages = await browser.pages();
50
+ let closed = 0;
51
+ await Promise.all(pages.map(async (page) => {
52
+ if (!page || page.isClosed() || page === keepPage) return;
53
+ let url = '';
54
+ try { url = page.url(); } catch { return; }
55
+ if (!isLocalVerificationUrl(url)) return;
56
+ if (expected && stripLocalCacheBust(url) !== expected) return;
57
+ try {
58
+ await page.close();
59
+ closed += 1;
60
+ } catch {}
61
+ }));
62
+ if (closed > 0) console.log(`[browser.js] Closed ${closed} stale verification tab(s) for ${targetUrl || 'local browser'}`);
63
+ }
64
+
28
65
  // Return the active page for this agent. Creates a fresh tab on first use.
29
66
  // preferUrl: hint — if the agent's tab already has a better match among all open
30
67
  // tabs (e.g. a link opened a new tab), update the agent's pointer to it.
@@ -37,7 +74,7 @@ async function getPage(agentId, preferUrl) {
37
74
  // If preferUrl given, check if any tab matches better (handles new-tab links)
38
75
  if (preferUrl) {
39
76
  const pages = await browser.pages();
40
- const match = pages.find(p => !p.isClosed() && p.url().includes(preferUrl));
77
+ const match = pages.find(p => !p.isClosed() && sameBrowserTargetUrl(p.url(), preferUrl));
41
78
  if (match && match !== existing) {
42
79
  _agentPages.set(agentId, match);
43
80
  return match;
@@ -46,6 +83,15 @@ async function getPage(agentId, preferUrl) {
46
83
  return existing;
47
84
  }
48
85
 
86
+ if (preferUrl) {
87
+ const pages = await browser.pages();
88
+ const match = pages.find(p => !p.isClosed() && sameBrowserTargetUrl(p.url(), preferUrl));
89
+ if (match) {
90
+ _agentPages.set(agentId, match);
91
+ return match;
92
+ }
93
+ }
94
+
49
95
  // Agent has no page — open a fresh tab so it doesn't land on another agent's tab
50
96
  const fresh = await browser.newPage();
51
97
  _agentPages.set(agentId, fresh);
@@ -71,23 +117,14 @@ function withLocalCacheBust(value) {
71
117
  }
72
118
 
73
119
  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) : '';
120
+ const expected = expectedUrl ? stripLocalCacheBust(expectedUrl) : '';
84
121
  for (let i = 0; i < 45; i++) {
85
122
  try {
86
123
  const state = await page.evaluate(() => ({
87
124
  href: location.href,
88
125
  readyState: document.readyState,
89
126
  }));
90
- if ((!expected || stripCacheBust(state.href) === expected) && state.readyState !== 'loading') return;
127
+ if ((!expected || stripLocalCacheBust(state.href) === expected) && state.readyState !== 'loading') return;
91
128
  } catch {
92
129
  // Page contexts can disappear while navigation commits.
93
130
  }
@@ -200,7 +237,7 @@ async function _browserActionInner(input, agentId, browser) {
200
237
  case 'navigate':
201
238
  case 'open': {
202
239
  const url = input.url || input.targetUrl;
203
- const page = await getPage(agentId);
240
+ const page = await getPage(agentId, url);
204
241
  await page.setCacheEnabled(false).catch(() => null);
205
242
  const navigationUrl = withLocalCacheBust(url);
206
243
  // Use Promise.race to enforce a hard 20s cap — page.goto timeout option can hang
@@ -215,6 +252,7 @@ async function _browserActionInner(input, agentId, browser) {
215
252
  _agentPages.set(agentId, page);
216
253
  // Brief pause for initial render, then snapshot
217
254
  await waitForPageReady(page, url);
255
+ await closeStaleVerificationTabs(browser, page, url);
218
256
  await _wait(400);
219
257
  return await _snapshot(page, agentId);
220
258
  }
package/src/selfUpdate.js CHANGED
@@ -6,8 +6,8 @@
6
6
  * prevent infinite re-exec loops).
7
7
  */
8
8
 
9
- import { execSync, spawn } from 'child_process';
10
- import { mkdirSync } from 'fs';
9
+ import { execFileSync, execSync, spawn } from 'child_process';
10
+ import { mkdirSync, rmSync } from 'fs';
11
11
  import { homedir } from 'os';
12
12
  import path from 'path';
13
13
 
@@ -75,22 +75,58 @@ export async function checkAndUpdate(packageName, currentVersion) {
75
75
 
76
76
  function installGlobalPackage(pkg, { force = false } = {}) {
77
77
  const forceFlag = force ? ' --force' : '';
78
+ const preflightEnv = ensureWritableGlobalInstallEnv();
78
79
  try {
79
- execSync(`npm install -g${forceFlag} ${pkg}`, { stdio: 'inherit' });
80
+ execSync(`npm install -g${forceFlag} ${pkg}`, { stdio: 'inherit', env: preflightEnv });
80
81
  return;
81
82
  } catch (error) {
82
83
  if (!looksLikePermissionFailure(error)) throw error;
83
84
  }
84
85
 
86
+ const env = configureUserOwnedNpmGlobalEnv();
87
+ execSync(`npm install -g${forceFlag} ${pkg}`, { stdio: 'inherit', env });
88
+ }
89
+
90
+ function npmConfigGet(args) {
91
+ try {
92
+ return execFileSync('npm', args, {
93
+ encoding: 'utf8',
94
+ stdio: ['ignore', 'pipe', 'ignore'],
95
+ }).trim();
96
+ } catch {
97
+ return '';
98
+ }
99
+ }
100
+
101
+ function canWriteNpmRoot(rootDir) {
102
+ if (!rootDir) return false;
103
+ const testDir = path.join(rootDir, `.agentforge-write-test-${process.pid}`);
104
+ try {
105
+ mkdirSync(testDir, { recursive: true });
106
+ rmSync(testDir, { recursive: true, force: true });
107
+ return true;
108
+ } catch {
109
+ try { rmSync(testDir, { recursive: true, force: true }); } catch {}
110
+ return false;
111
+ }
112
+ }
113
+
114
+ function ensureWritableGlobalInstallEnv() {
115
+ const root = npmConfigGet(['root', '-g']);
116
+ if (canWriteNpmRoot(root)) return process.env;
117
+ return configureUserOwnedNpmGlobalEnv();
118
+ }
119
+
120
+ function configureUserOwnedNpmGlobalEnv() {
85
121
  const prefix = path.join(homedir(), '.npm-global');
86
122
  mkdirSync(path.join(prefix, 'bin'), { recursive: true });
87
123
  mkdirSync(path.join(prefix, 'lib', 'node_modules'), { recursive: true });
88
- execSync(`npm config set prefix "${prefix}"`, { stdio: 'inherit' });
124
+ execFileSync('npm', ['config', 'set', 'prefix', prefix], { stdio: ['ignore', 'ignore', 'inherit'] });
89
125
  const env = {
90
126
  ...process.env,
91
127
  PATH: `${path.join(prefix, 'bin')}${path.delimiter}${process.env.PATH || ''}`,
92
128
  };
93
- execSync(`npm install -g${forceFlag} ${pkg}`, { stdio: 'inherit', env });
129
+ return env;
94
130
  }
95
131
 
96
132
  function looksLikePermissionFailure(error) {