@hamp10/agentforge 0.2.21 → 0.2.23

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/src/browser.js ADDED
@@ -0,0 +1,392 @@
1
+ /**
2
+ * AgentForge Browser Tool — Puppeteer connected to AgentForge Browser (port 9223)
3
+ * Uses puppeteer-core to attach to the already-running AgentForge Browser.
4
+ *
5
+ * Tab isolation: each agent gets its own dedicated tab so concurrent agents
6
+ * never interfere with each other. Pass agentId to browserAction().
7
+ */
8
+
9
+ import puppeteer from 'puppeteer-core';
10
+
11
+ const BROWSER_URL = 'http://127.0.0.1:9223';
12
+
13
+ let _browser = null;
14
+ // Per-agent active page map — agents never share a page reference
15
+ const _agentPages = new Map();
16
+
17
+ async function getBrowser() {
18
+ if (_browser && _browser.isConnected()) return _browser;
19
+ _browser = await puppeteer.connect({
20
+ browserURL: BROWSER_URL,
21
+ defaultViewport: null,
22
+ protocolTimeout: 60000,
23
+ });
24
+ _browser.on('disconnected', () => { _browser = null; _agentPages.clear(); });
25
+ return _browser;
26
+ }
27
+
28
+ // Return the active page for this agent. Creates a fresh tab on first use.
29
+ // preferUrl: hint — if the agent's tab already has a better match among all open
30
+ // tabs (e.g. a link opened a new tab), update the agent's pointer to it.
31
+ async function getPage(agentId, preferUrl) {
32
+ const browser = await getBrowser();
33
+
34
+ // Check if the agent already has a live page
35
+ const existing = _agentPages.get(agentId);
36
+ if (existing && !existing.isClosed()) {
37
+ // If preferUrl given, check if any tab matches better (handles new-tab links)
38
+ if (preferUrl) {
39
+ const pages = await browser.pages();
40
+ const match = pages.find(p => !p.isClosed() && p.url().includes(preferUrl));
41
+ if (match && match !== existing) {
42
+ _agentPages.set(agentId, match);
43
+ return match;
44
+ }
45
+ }
46
+ return existing;
47
+ }
48
+
49
+ // Agent has no page — open a fresh tab so it doesn't land on another agent's tab
50
+ const fresh = await browser.newPage();
51
+ _agentPages.set(agentId, fresh);
52
+ return fresh;
53
+ }
54
+
55
+ const _wait = (ms) => new Promise(r => setTimeout(r, ms));
56
+
57
+ // Take a structured snapshot of a page — content + interactive elements + all inputs
58
+ async function _snapshot(page, agentId) {
59
+ const url = page.url();
60
+ let title = '';
61
+ try { title = await page.title(); } catch {}
62
+
63
+ const browser = await getBrowser();
64
+ const allPages = await browser.pages();
65
+ const open = allPages.filter(p => !p.isClosed());
66
+ const tabLines = await Promise.all(open.map(async (p, i) => {
67
+ const u = p.url();
68
+ let t = '';
69
+ try { t = await p.title(); } catch {}
70
+ const active = (p === page) ? ' ← ACTIVE' : '';
71
+ return ` [tab ${i}] ${t || '(no title)'} — ${u}${active}`;
72
+ }));
73
+
74
+ const data = await Promise.race([
75
+ page.evaluate(() => {
76
+ // All interactive clickable elements (buttons, links, tabs)
77
+ const clickEls = [...document.querySelectorAll('a,button,[role="button"],[role="link"],[role="tab"],[role="menuitem"]')];
78
+ const interactive = clickEls.slice(0, 80).map((el, i) => {
79
+ const label = (el.textContent || el.getAttribute('aria-label') || el.getAttribute('title') || '').trim().replace(/\s+/g, ' ').slice(0, 80);
80
+ return label ? `[${i}] ${el.tagName.toLowerCase()}: ${label}` : null;
81
+ }).filter(Boolean);
82
+
83
+ // All form inputs — these are critical for filling in filters
84
+ const formEls = [...document.querySelectorAll('input,select,textarea')];
85
+ const inputs = formEls.slice(0, 40).map((el, i) => {
86
+ const name = el.name || el.id || el.getAttribute('placeholder') || '';
87
+ const type = el.type || el.tagName.toLowerCase();
88
+ const val = el.value || '';
89
+ const opts = el.tagName === 'SELECT'
90
+ ? Array.from(el.options).map(o => o.text.trim()).filter(Boolean).slice(0, 20).join(' | ')
91
+ : '';
92
+ return `[input-${i}] ${type} name="${name}" value="${val}"${opts ? ` options: ${opts}` : ''}`;
93
+ });
94
+
95
+ // Page text — scroll position 0 so we always capture the top of the page
96
+ window.scrollTo(0, 0);
97
+ const bodyText = (document.body?.innerText || '').replace(/\n{3,}/g, '\n\n').slice(0, 8000);
98
+
99
+ return { interactive, inputs, bodyText };
100
+ }),
101
+ new Promise((_, rej) => setTimeout(() => rej(new Error('page.evaluate() timed out (10s)')), 10000)),
102
+ ]).catch(err => { console.log(`[browser.js] snapshot evaluate error: ${err.message}`); return { interactive: [], inputs: [], bodyText: '' }; });
103
+
104
+ const lines = [
105
+ `Open tabs:`,
106
+ ...tabLines,
107
+ ``,
108
+ `Active tab — URL: ${url}`,
109
+ `Title: ${title}`,
110
+ ];
111
+
112
+ if (data.interactive.length) {
113
+ lines.push(``, `Clickable elements (use ref index with click action):`);
114
+ lines.push(...data.interactive);
115
+ }
116
+
117
+ if (data.inputs.length) {
118
+ lines.push(``, `Form inputs (use selector="#id" or selector="[name=x]" with type action):`);
119
+ lines.push(...data.inputs);
120
+ }
121
+
122
+ lines.push(``, `Page content:`, data.bodyText);
123
+
124
+ // Thin-content warning — JS-heavy pages need a screenshot to read properly
125
+ const bodyLen = (data.bodyText || '').trim().length;
126
+ if (bodyLen < 500) {
127
+ lines.push(``, `⚠️ Page content is limited (${bodyLen} chars) — this page likely loads content via JavaScript. Use {"action":"screenshot"} to see exactly what is on screen right now.`);
128
+ }
129
+
130
+ return lines.join('\n');
131
+ }
132
+
133
+ // Release an agent's tab reference when their task is done.
134
+ // Tab is intentionally kept open so the user can see the final state of the app.
135
+ export function releaseAgentTab(agentId) {
136
+ _agentPages.delete(agentId);
137
+ }
138
+
139
+ // Hard outer timeout for any browser action — prevents any CDP call from hanging forever.
140
+ // Most actions (navigate, snapshot) should complete in <15s. We give 25s for safety.
141
+ const BROWSER_ACTION_TIMEOUT_MS = 25000;
142
+
143
+ async function _browserActionInner(input, agentId, browser) {
144
+ try {
145
+ switch (input.action) {
146
+
147
+ case 'tabs': {
148
+ const pages = await browser.pages();
149
+ const open = pages.filter(p => !p.isClosed());
150
+ const lines = await Promise.all(open.map(async (p, i) => {
151
+ const url = p.url();
152
+ let title = '';
153
+ try { title = await p.title(); } catch {}
154
+ return `[${i}] ${title || '(no title)'} — ${url}`;
155
+ }));
156
+ return `Open tabs (${open.length}):\n${lines.join('\n')}\n\nTo switch to a tab: {"action":"focus","url":"fragment"} or {"action":"focus","index":N}`;
157
+ }
158
+
159
+ case 'navigate':
160
+ case 'open': {
161
+ const url = input.url || input.targetUrl;
162
+ const page = await getPage(agentId);
163
+ // Use Promise.race to enforce a hard 20s cap — page.goto timeout option can hang
164
+ const navResult = await Promise.race([
165
+ page.goto(url, { waitUntil: 'domcontentloaded', timeout: 20000 }),
166
+ new Promise((_, rej) => setTimeout(() => rej(new Error(`Navigation hard-timeout (20s): ${url}`)), 20000)),
167
+ ]).catch(err => ({ __navError: err.message }));
168
+ if (navResult && navResult.__navError) {
169
+ // Navigation timed out but page may have partially loaded — still snapshot it
170
+ console.log(`[browser.js] navigate timeout for ${url}: ${navResult.__navError}`);
171
+ }
172
+ _agentPages.set(agentId, page);
173
+ // Brief pause for initial render, then snapshot
174
+ await _wait(400);
175
+ return await _snapshot(page, agentId);
176
+ }
177
+
178
+ case 'focus': {
179
+ const pages = await browser.pages();
180
+ const open = pages.filter(p => !p.isClosed());
181
+ let target;
182
+ if (input.index !== undefined) {
183
+ target = open[parseInt(input.index)];
184
+ } else if (input.url) {
185
+ target = open.find(p => p.url().includes(input.url));
186
+ }
187
+ if (!target) return `Tab not found. Available tabs:\n${open.map((p,i) => `[${i}] ${p.url()}`).join('\n')}`;
188
+ await target.bringToFront();
189
+ _agentPages.set(agentId, target);
190
+ return await _snapshot(target, agentId);
191
+ }
192
+
193
+ case 'snapshot': {
194
+ const page = await getPage(agentId, input.url);
195
+ return await _snapshot(page, agentId);
196
+ }
197
+
198
+ case 'screenshot': {
199
+ const page = await getPage(agentId, input.url);
200
+ const buf = await page.screenshot({ type: 'png', fullPage: false });
201
+ return { __screenshot: true, base64: buf.toString('base64') };
202
+ }
203
+
204
+ case 'click': {
205
+ const page = await getPage(agentId);
206
+
207
+ // Snapshot pages BEFORE click so we can detect truly new tabs (not background tabs)
208
+ const pagesBefore = new Set((await browser.pages()).map(p => p.target()._targetId || p.url()));
209
+
210
+ let clickResult = null;
211
+
212
+ if (input.ref !== undefined) {
213
+ clickResult = await page.evaluate((idx) => {
214
+ const els = document.querySelectorAll('a,button,[role="button"],[role="link"],[role="tab"],[role="menuitem"]');
215
+ const el = els[idx];
216
+ if (el) { el.focus(); el.click(); return `[${idx}] ${el.tagName}: ${(el.textContent||'').trim().slice(0,80)}`; }
217
+ return null;
218
+ }, parseInt(input.ref));
219
+ } else if (input.text) {
220
+ clickResult = await page.evaluate((text) => {
221
+ const all = Array.from(document.querySelectorAll('a,button,input,select,span,div,th,td,li,label,[role="button"]'));
222
+ const el = all.find(e => e.textContent.trim().includes(text) && e.offsetParent !== null);
223
+ if (el) { el.click(); return `${el.tagName}: ${el.textContent.trim().slice(0, 80)}`; }
224
+ return null;
225
+ }, input.text);
226
+ } else if (input.selector) {
227
+ await page.click(input.selector);
228
+ clickResult = `clicked ${input.selector}`;
229
+ } else if (input.x !== undefined) {
230
+ await page.mouse.click(input.x, input.y || 0);
231
+ clickResult = `clicked (${input.x}, ${input.y})`;
232
+ }
233
+
234
+ if (!clickResult) {
235
+ return input.ref !== undefined
236
+ ? `Element [${input.ref}] not found — take a snapshot to get current refs`
237
+ : `No visible element found matching "${input.text || input.selector}"`;
238
+ }
239
+
240
+ // Brief pause for dynamic content to render
241
+ await _wait(300);
242
+
243
+ // Check if this click opened a genuinely new tab (compare against pre-click snapshot)
244
+ const pagesAfter = await browser.pages();
245
+ const agentPage = _agentPages.get(agentId);
246
+ const newTab = pagesAfter.find(p => {
247
+ if (p.isClosed() || p === agentPage) return false;
248
+ const id = p.target()._targetId || p.url();
249
+ return !pagesBefore.has(id) && !p.url().startsWith('about') && !p.url().startsWith('chrome');
250
+ });
251
+ if (newTab) {
252
+ _agentPages.set(agentId, newTab);
253
+ await _wait(600); // let new tab load
254
+ const snap = await _snapshot(newTab, agentId);
255
+ return `Clicked: ${clickResult}\n\n⚠️ New tab opened — now viewing new tab.\n--- Page state after click ---\n${snap}`;
256
+ }
257
+
258
+ // Always return a fresh snapshot after clicking so agent immediately sees what changed
259
+ const snap = await _snapshot(page, agentId);
260
+ return `Clicked: ${clickResult}\n\n--- Page state after click ---\n${snap}`;
261
+ }
262
+
263
+ case 'type': {
264
+ const page = await getPage(agentId);
265
+ const text = input.text || '';
266
+ if (input.selector) {
267
+ await page.focus(input.selector);
268
+ await page.evaluate((sel) => {
269
+ const el = document.querySelector(sel);
270
+ if (el) { el.value = ''; el.dispatchEvent(new Event('input', {bubbles:true})); }
271
+ }, input.selector);
272
+ await page.type(input.selector, text, { delay: 30 });
273
+ await page.evaluate((sel) => {
274
+ const el = document.querySelector(sel);
275
+ if (el) el.dispatchEvent(new Event('change', {bubbles:true}));
276
+ }, input.selector);
277
+ } else {
278
+ await page.keyboard.type(text, { delay: 30 });
279
+ }
280
+ await _wait(500);
281
+ return `Typed "${text.slice(0, 60)}" — take a snapshot to see the result`;
282
+ }
283
+
284
+ case 'act': {
285
+ const req = input.request || {};
286
+ if (req.kind === 'click') return browserAction({ action: 'click', ref: req.ref, selector: req.selector }, agentId);
287
+ if (req.kind === 'type') return browserAction({ action: 'type', selector: req.selector, text: req.text }, agentId);
288
+ return `Unknown act kind: ${req.kind}`;
289
+ }
290
+
291
+ case 'press': {
292
+ // Press a keyboard key on the active page — use "Enter" to submit forms.
293
+ // Pass selector to re-focus an element first (prevents focus drift during model thinking gaps).
294
+ const page = await getPage(agentId);
295
+ const key = input.key || 'Enter';
296
+ if (input.selector) {
297
+ try { await page.focus(input.selector); } catch {}
298
+ await _wait(100); // brief settle after focus
299
+ }
300
+ await page.keyboard.press(key);
301
+ await _wait(800); // let the page react (navigation, form submission, etc.)
302
+ return await _snapshot(page, agentId);
303
+ }
304
+
305
+ case 'scroll': {
306
+ const page = await getPage(agentId);
307
+ const dx = input.x || 0, dy = input.y || 400;
308
+ await page.evaluate((x, y) => window.scrollBy(x, y), dx, dy);
309
+ await _wait(300);
310
+ return await _snapshot(page, agentId);
311
+ }
312
+
313
+ case 'evaluate': {
314
+ const page = await getPage(agentId);
315
+ const result = await page.evaluate(input.script || input.expression || '');
316
+ return result === undefined ? 'undefined' : JSON.stringify(result);
317
+ }
318
+
319
+ case 'back': {
320
+ const page = await getPage(agentId);
321
+ await page.goBack({ waitUntil: 'domcontentloaded', timeout: 10000 });
322
+ await _wait(500);
323
+ return await _snapshot(page, agentId);
324
+ }
325
+
326
+ case 'forward': {
327
+ const page = await getPage(agentId);
328
+ await page.goForward({ waitUntil: 'domcontentloaded', timeout: 10000 });
329
+ await _wait(500);
330
+ return await _snapshot(page, agentId);
331
+ }
332
+
333
+ case 'wait': {
334
+ await _wait(input.ms || 1000);
335
+ const page = await getPage(agentId);
336
+ return await _snapshot(page, agentId);
337
+ }
338
+
339
+ case 'url': {
340
+ const page = await getPage(agentId);
341
+ return page.url();
342
+ }
343
+
344
+ case 'reload': {
345
+ const page = await getPage(agentId);
346
+ await page.reload({ waitUntil: 'domcontentloaded', timeout: 15000 });
347
+ await _wait(500);
348
+ return await _snapshot(page, agentId);
349
+ }
350
+
351
+ case 'select': {
352
+ const page = await getPage(agentId);
353
+ await page.select(input.selector, input.value);
354
+ await _wait(500);
355
+ return await _snapshot(page, agentId);
356
+ }
357
+
358
+ default:
359
+ return `Unknown browser action: ${input.action}. Valid: tabs, navigate, open, focus, snapshot, click, type, screenshot, scroll, evaluate, select, press, wait, reload, back, forward, url`;
360
+ }
361
+ } catch (err) {
362
+ return `Browser error (${input.action}): ${err.message}`;
363
+ }
364
+ }
365
+
366
+ export async function browserAction(input, agentId = 'agent') {
367
+ let browser;
368
+ try {
369
+ browser = await Promise.race([
370
+ getBrowser(),
371
+ new Promise((_, rej) => setTimeout(() => rej(new Error('getBrowser() timed out after 10s')), 10000)),
372
+ ]);
373
+ } catch (err) {
374
+ return `Browser error: ${err.message}\nIs AgentForge Browser running? (port 9223)`;
375
+ }
376
+
377
+ // Wrap the entire action in a hard timeout — prevents getPage(), page.goto(), page.evaluate()
378
+ // and any other CDP call from hanging indefinitely.
379
+ try {
380
+ const result = await Promise.race([
381
+ _browserActionInner(input, agentId, browser),
382
+ new Promise((_, rej) => setTimeout(() => rej(new Error(`Browser action timed out after ${BROWSER_ACTION_TIMEOUT_MS/1000}s (action=${input.action})`)), BROWSER_ACTION_TIMEOUT_MS)),
383
+ ]);
384
+ return result;
385
+ } catch (err) {
386
+ console.log(`[browser.js] ⏱️ Hard timeout: ${err.message}`);
387
+ // Reset stale connection — next action will reconnect fresh to Chrome CDP
388
+ _browser = null;
389
+ _agentPages.clear();
390
+ return `Browser error (${input.action}): ${err.message}`;
391
+ }
392
+ }
@@ -0,0 +1,95 @@
1
+ export const TASK_GUIDE_TYPE_LABELS = Object.freeze({
2
+ general: 'General',
3
+ ui: 'UI',
4
+ code: 'Code',
5
+ research: 'Research',
6
+ data: 'Data',
7
+ planning: 'Planning',
8
+ creative: 'Creative',
9
+ });
10
+
11
+ const UI_PRODUCT_QUALITY_CONTENT = `UI work must result in a real product surface, not superficial styling.
12
+
13
+ Required process:
14
+ 1. On the initial attempt, inspect the actual app visually first with browser/screenshot. Use DOM snapshots only to locate controls.
15
+ 2. For existing products, inspect at least two nearby or comparable live surfaces when they exist, plus the source files/templates/styles that create those surfaces. Use them to learn the product's strongest layout patterns, content depth, interaction patterns, and quality bar before changing the target surface. After you start editing a narrowly scoped target, do not drift into reference surfaces as work targets; final browser verification must be on the changed target surface(s).
16
+ 3. When the task names a narrow page, listing, route, screen, or component, find analogous pages/components in the same project and read their code before rebuilding. Treat those analogous files as read-only reference context unless the user named them too. Match the established system while making the target surface as strong as the best local examples.
17
+ 4. Identify the product domain, primary user job, key workflow, empty/error/loading states, and what makes the current UI feel unfinished.
18
+ 5. Make product-level changes: information architecture, workflow, hierarchy, components, states, copy, data density, responsiveness, and visual system. Color-only, label-only, spacing-only, or hero-only passes are incomplete.
19
+ 6. Preserve real capability. If keys/auth/data/devices/models are missing, design useful setup, empty, disabled, demo, or recovery states instead of deleting complexity.
20
+ 7. Translate the task language into first-class product objects, workflow states, outcomes, and primary actions. Keep vendor, model, framework, or implementation details secondary unless the user is explicitly configuring them.
21
+ 8. For apps, tools, dashboards, games, and operational products, the first screen must be the actual working surface. Do not replace the app with a marketing hero, explainer page, or decorative showcase.
22
+ 9. Respect the requested scope. When the user names specific pages, screens, listings, flows, or components, improve those target surfaces and their directly owned assets. Put page-specific styles/content in target-owned files where possible. Do not redesign shared/global navigation, templates, styles, reference pages, or unrelated pages unless the target cannot work without it; if shared files are touched, verify adjacent surfaces did not regress and explain why the shared change was necessary.
23
+ 10. On retries, nudges, or runtime recovery after inspection/read/browser activity already happened, do not restart discovery. Continue from current files and tool results; the next material step should be an edit/write or a focused verification of changed UI. If a UI-quality attempt was restored or rejected as too thin/shallow, change strategy instead of redoing small CSS, copy, badge, icon, CTA, or hero edits.
24
+ 11. If repeated targeted edits are failing or accumulating as micro-edits, replace the complete scoped file from the latest inspected source instead of chaining many small stale-string replacements. If source-artifact feedback appears, clean it up as part of the next pass, but do not treat cleanup as the design fix.
25
+
26
+ Quality bar:
27
+ - The first screen shows what matters now and what to do next within a few seconds.
28
+ - The first screen is not just a sparse headline/subtitle/button on a blank background. It should show meaningful product content, workflow, comparison, data, media, examples, or other concrete structure before the user has to hunt for the substance.
29
+ - Controls have visible default, hover/focus, selected, disabled, loading, success, error, and empty states where relevant.
30
+ - Layout survives desktop and narrow widths; text does not overflow, overlap, clip, or become unreadable.
31
+ - Accessibility is built in: contrast, focus, keyboard flow, target size, and clear status/error messages.
32
+ - Screenshots must be checked for unintended visual artifacts: clipped or partially cut-off letters/text, broken layering, abrupt discontinuities, accidental overlays, tiling/repeating backgrounds, broken transparency, visible crop edges, and anything that reads as rendering damage rather than deliberate design.
33
+ - Keep technical/provider/model configuration out of the primary workflow unless that configuration is the product's main job. Use advanced settings, secondary panels, or defaults for implementation details.
34
+ - Remove generic AI-dashboard filler, placeholder panels, duplicated sections, mismatched spacing/radii/shadows, dead buttons, decorative gradients, and external visual assets that do not support the task.
35
+ - Avoid one-note visual systems dominated by a single hue, glow, or gradient. Use restrained hierarchy, purposeful contrast, and domain-specific objects instead of decoration.
36
+ - The changed screen should hold up next to the product's own strongest screens and normal professional design standards. If it looks thinner, rougher, less considered, or less coherent than nearby surfaces, keep iterating.
37
+
38
+ Completion rule:
39
+ Run/inspect the changed UI visually, compare it against the product context you inspected, critique it once, fix material issues, and repeat until there are no obvious quality gaps. Then finish with concrete changed screens/files and blockers. Do not mark complete after planning, reading files, or making generic polish.`;
40
+
41
+ export const DEFAULT_TASK_GUIDES = Object.freeze({
42
+ ui: Object.freeze({
43
+ idSuffix: 'ui-product-quality-v10',
44
+ name: 'UI/UX Product Design Quality Gate',
45
+ description: 'Requires agents to deliver researched, complete, domain-aware product UX instead of superficial visual tweaks.',
46
+ task_type: 'ui',
47
+ trigger_keywords: Object.freeze([
48
+ 'ui', 'ux', 'visual', 'design', 'frontend', 'css', 'html', 'page',
49
+ 'dashboard', 'website', 'app', 'layout', 'component', 'responsive',
50
+ 'professional', 'polish', 'vibecoded', 'vibe-coded', 'landing',
51
+ 'empty state', 'workflow', 'product design', 'interface', 'screen',
52
+ 'visual hierarchy', 'user experience', 'design system', 'readability',
53
+ 'readable', 'legibility', 'contrast', 'accessibility',
54
+ ]),
55
+ priority: 300,
56
+ content: UI_PRODUCT_QUALITY_CONTENT,
57
+ }),
58
+ code: Object.freeze({
59
+ idSuffix: 'code-delivery-v1',
60
+ name: 'Code Delivery',
61
+ description: 'Keeps implementation and shipping tasks grounded in existing project structure and real delivery evidence.',
62
+ task_type: 'code',
63
+ trigger_keywords: Object.freeze([
64
+ 'code', 'repo', 'project', 'bug', 'fix', 'implement', 'refactor', 'test',
65
+ 'commit', 'push', 'publish', 'deploy', 'release', 'production', 'live',
66
+ 'ship', 'shipping',
67
+ ]),
68
+ priority: 100,
69
+ content: `For code tasks:
70
+ - Read the existing project structure before editing.
71
+ - Follow local patterns and preserve unrelated user changes.
72
+ - Make the smallest complete change that solves the task.
73
+ - Run the relevant checks or explain exactly why they could not run.
74
+ - Do not create throwaway proof files such as verification notes, screenshots, or status artifacts inside the user's repo unless the user asked for them.
75
+ - Do not create or switch git branches unless the user asked for a branch or pull request. If the user asks to commit and push, deliver on the current branch/upstream.
76
+ - If the user asks to commit, push, publish, deploy, release, or update a live site, that delivery step is part of the task. Do not report completion until you have run the matching git/deploy/publish command, verified the target state, and reported exact evidence.
77
+ - Report changed files and remaining blockers only after implementation is complete.`,
78
+ }),
79
+ });
80
+
81
+ export const SERVER_DEFAULT_TASK_GUIDES = Object.freeze([
82
+ DEFAULT_TASK_GUIDES.ui,
83
+ DEFAULT_TASK_GUIDES.code,
84
+ ]);
85
+
86
+ export function getStarterTaskGuide(type) {
87
+ const guide = DEFAULT_TASK_GUIDES[type];
88
+ if (!guide) return null;
89
+ return {
90
+ name: guide.name,
91
+ description: guide.description,
92
+ keywords: guide.trigger_keywords.join(', '),
93
+ content: guide.content,
94
+ };
95
+ }
@@ -11,7 +11,7 @@
11
11
  */
12
12
 
13
13
  import { execSync } from 'child_process';
14
- import { existsSync } from 'fs';
14
+ import { existsSync, readdirSync } from 'fs';
15
15
  import { homedir } from 'os';
16
16
  import path from 'path';
17
17
 
@@ -23,6 +23,25 @@ function tryExec(cmd) {
23
23
  }
24
24
  }
25
25
 
26
+ function tryReadDir(dir) {
27
+ try {
28
+ return readdirSync(dir, { withFileTypes: true });
29
+ } catch {
30
+ return [];
31
+ }
32
+ }
33
+
34
+ function uniqueExisting(paths) {
35
+ return [...new Set(paths.filter(Boolean).map(p => path.normalize(p)))].filter(p => existsSync(p));
36
+ }
37
+
38
+ function nvmNodeDirs(home) {
39
+ const base = path.join(home, '.nvm', 'versions', 'node');
40
+ return tryReadDir(base)
41
+ .filter(entry => entry.isDirectory())
42
+ .map(entry => path.join(base, entry.name));
43
+ }
44
+
26
45
  // Walk up from a binary path to find dist/index.js in the containing package.
27
46
  function findModuleIndex(binPath) {
28
47
  if (!binPath) return null;
@@ -54,7 +73,7 @@ export function resolveOpenclawModule() {
54
73
  // 2. which openclaw → resolve symlink → find dist/index.js
55
74
  const which = tryExec('which openclaw');
56
75
  if (which && existsSync(which)) {
57
- const real = tryExec(`readlink -f "${which}"`) || which;
76
+ const real = tryExec(`readlink -f "${which}"`) || tryExec(`realpath "${which}"`) || which;
58
77
  const idx = findModuleIndex(real);
59
78
  if (idx) return idx;
60
79
  // which found the binary but we couldn't find index.js — try npm root
@@ -69,17 +88,22 @@ export function resolveOpenclawModule() {
69
88
 
70
89
  // 4. Static fallbacks for known install locations
71
90
  const home = process.env.HOME || homedir();
91
+ const npmPrefix = tryExec('npm config get prefix');
92
+ const nodeDirs = nvmNodeDirs(home);
72
93
  const statics = [
73
94
  '/usr/local/lib/node_modules/openclaw/dist/index.js',
74
95
  path.join(home, '.npm-global/lib/node_modules/openclaw/dist/index.js'),
96
+ path.join(home, '.local/lib/node_modules/openclaw/dist/index.js'),
97
+ path.join(home, '.volta/tools/image/packages/openclaw/lib/node_modules/openclaw/dist/index.js'),
75
98
  '/opt/homebrew/lib/node_modules/openclaw/dist/index.js',
76
99
  // nvm — try the active version via node path
77
100
  path.join(path.dirname(process.execPath), '..', 'lib', 'node_modules', 'openclaw', 'dist', 'index.js'),
78
- ].map(p => path.normalize(p));
101
+ npmPrefix ? path.join(npmPrefix, 'lib', 'node_modules', 'openclaw', 'dist', 'index.js') : null,
102
+ ...nodeDirs.map(dir => path.join(dir, 'lib', 'node_modules', 'openclaw', 'dist', 'index.js')),
103
+ ];
79
104
 
80
- for (const p of statics) {
81
- if (existsSync(p)) return p;
82
- }
105
+ const existing = uniqueExisting(statics);
106
+ if (existing.length > 0) return existing[0];
83
107
 
84
108
  return null;
85
109
  }
@@ -95,11 +119,18 @@ export function resolveOpenclawBin() {
95
119
  if (which && existsSync(which)) return which;
96
120
 
97
121
  const home = process.env.HOME || homedir();
122
+ const npmPrefix = tryExec('npm config get prefix');
123
+ const nodeDirs = nvmNodeDirs(home);
98
124
  const statics = [
99
125
  '/usr/local/bin/openclaw',
100
126
  path.join(home, '.npm-global/bin/openclaw'),
127
+ path.join(home, '.local/bin/openclaw'),
128
+ path.join(home, '.volta/bin/openclaw'),
129
+ path.join(home, '.asdf/shims/openclaw'),
101
130
  '/opt/homebrew/bin/openclaw',
102
131
  path.join(path.dirname(process.execPath), 'openclaw'),
132
+ npmPrefix ? path.join(npmPrefix, 'bin', 'openclaw') : null,
133
+ ...nodeDirs.map(dir => path.join(dir, 'bin', 'openclaw')),
103
134
  ];
104
- return statics.find(p => existsSync(p)) || null;
135
+ return uniqueExisting(statics)[0] || null;
105
136
  }
package/src/selfUpdate.js CHANGED
@@ -7,6 +7,9 @@
7
7
  */
8
8
 
9
9
  import { execSync, spawn } from 'child_process';
10
+ import { mkdirSync } from 'fs';
11
+ import { homedir } from 'os';
12
+ import path from 'path';
10
13
 
11
14
  function parseVersion(v) {
12
15
  return (v || '0.0.0').split('.').map(Number);
@@ -44,11 +47,11 @@ export async function checkAndUpdate(packageName, currentVersion) {
44
47
  console.log(` Updating ${packageName}...`);
45
48
 
46
49
  try {
47
- execSync(`npm install -g ${packageName}@${latestVersion}`, { stdio: 'inherit' });
50
+ installGlobalPackage(`${packageName}@${latestVersion}`);
48
51
  } catch {
49
- // First attempt failed (often EEXIST on the bin symlink) — retry with --force
52
+ // First attempt failed (often EEXIST on the bin symlink) — retry with --force.
50
53
  try {
51
- execSync(`npm install -g --force ${packageName}@${latestVersion}`, { stdio: 'inherit' });
54
+ installGlobalPackage(`${packageName}@${latestVersion}`, { force: true });
52
55
  } catch {
53
56
  console.warn('⚠️ Auto-update failed — continuing with current version');
54
57
  return;
@@ -69,3 +72,28 @@ export async function checkAndUpdate(packageName, currentVersion) {
69
72
  // Park the parent — child owns the terminal from here
70
73
  await new Promise(() => {});
71
74
  }
75
+
76
+ function installGlobalPackage(pkg, { force = false } = {}) {
77
+ const forceFlag = force ? ' --force' : '';
78
+ try {
79
+ execSync(`npm install -g${forceFlag} ${pkg}`, { stdio: 'inherit' });
80
+ return;
81
+ } catch (error) {
82
+ if (!looksLikePermissionFailure(error)) throw error;
83
+ }
84
+
85
+ const prefix = path.join(homedir(), '.npm-global');
86
+ mkdirSync(path.join(prefix, 'bin'), { recursive: true });
87
+ mkdirSync(path.join(prefix, 'lib', 'node_modules'), { recursive: true });
88
+ execSync(`npm config set prefix "${prefix}"`, { stdio: 'inherit' });
89
+ const env = {
90
+ ...process.env,
91
+ PATH: `${path.join(prefix, 'bin')}${path.delimiter}${process.env.PATH || ''}`,
92
+ };
93
+ execSync(`npm install -g${forceFlag} ${pkg}`, { stdio: 'inherit', env });
94
+ }
95
+
96
+ function looksLikePermissionFailure(error) {
97
+ const text = `${error?.message || ''}\n${error?.stderr?.toString?.() || ''}\n${error?.stdout?.toString?.() || ''}`;
98
+ return /EACCES|permission denied|access/i.test(text);
99
+ }