loom-agent 1.2.33 → 1.2.37
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/CHANGELOG.md +118 -0
- package/README.md +14 -30
- package/bin/loom-bun.js +95 -67
- package/bin/loom-tui.js +21 -1
- package/bin/loom.js +0 -10
- package/package.json +14 -9
- package/scripts/postinstall.js +57 -5
- package/src/connectors/convex.js +356 -0
- package/src/connectors/deploy.js +390 -0
- package/src/connectors/index.js +23 -0
- package/src/connectors/supabase.js +279 -0
- package/src/core/agents.js +32 -2
- package/src/core/cli.js +56 -7
- package/src/core/permissions.js +12 -1
- package/src/core/plugin-cmd.js +1 -0
- package/src/core/session.js +87 -6
- package/src/mcp/mcp-client.js +42 -2
- package/src/skills/skills-manager.js +25 -2
- package/src/tools/browser.js +204 -0
- package/src/tools/index.js +116 -5
- package/src/tui/App.tsx +19 -4
- package/src/tui/components/BreadcrumbBar.tsx +10 -9
- package/src/tui/components/ChatArea.tsx +3 -3
- package/src/tui/components/InputBar.tsx +22 -11
- package/src/tui/components/MdText.tsx +2 -1
- package/src/tui/components/Modals.tsx +31 -17
- package/src/tui/components/PermissionPopup.tsx +15 -12
- package/src/tui/components/Sidebar.tsx +6 -2
- package/src/tui/components/SplashScreen.tsx +17 -24
- package/src/tui/components/SubagentPanel.tsx +1 -1
- package/src/tui/components/ToastOverlay.tsx +23 -12
- package/src/tui/store.ts +4 -2
- package/src/tui/theme.ts +45 -30
- package/src/tui/tool-display.ts +13 -2
- package/docs/web.md +0 -205
- package/src/web/attach.js +0 -242
- package/src/web/graph-view.html +0 -262
- package/src/web/index.html +0 -824
- package/src/web/web-server.js +0 -470
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
// Headless browser control for the Web agent (and any build-mode session).
|
|
2
|
+
// Wraps Playwright behind a single-page singleton: the first action launches
|
|
3
|
+
// a headless Chromium and every later action reuses it, so "open a page, click
|
|
4
|
+
// around, read the DOM" works across tool calls without re-launching.
|
|
5
|
+
//
|
|
6
|
+
// Engine resolution order:
|
|
7
|
+
// 1. `playwright` (bundled chromium download, if the user installed it)
|
|
8
|
+
// 2. `playwright-core` + a system Chrome / Edge channel (zero download)
|
|
9
|
+
// Everything runs in the page context only — no filesystem access beyond the
|
|
10
|
+
// screenshot path the caller passes (enforced by the tool-layer sandbox for
|
|
11
|
+
// writes is intentionally NOT applied here: screenshots land in the OS temp
|
|
12
|
+
// dir by default and the tool returns the absolute path).
|
|
13
|
+
const fs = require('fs');
|
|
14
|
+
const os = require('os');
|
|
15
|
+
const path = require('path');
|
|
16
|
+
|
|
17
|
+
const INSTALL_HINT = 'Install a browser engine: npm i playwright then npx playwright install chromium (or install playwright-core and have Chrome/Edge available)';
|
|
18
|
+
const ACTION_TIMEOUT_MS = 30000;
|
|
19
|
+
|
|
20
|
+
/** @type {{ browser: any, page: any }|null} */
|
|
21
|
+
let _engine = null;
|
|
22
|
+
let _playwright = null; // null = not tried, false = unavailable, else module
|
|
23
|
+
|
|
24
|
+
const ACTIONS = ['goto', 'click', 'fill', 'press', 'text', 'eval', 'links', 'screenshot', 'close'];
|
|
25
|
+
|
|
26
|
+
function loadPlaywright() {
|
|
27
|
+
if (_playwright !== null) return _playwright || null;
|
|
28
|
+
for (const mod of ['playwright', 'playwright-core']) {
|
|
29
|
+
try { _playwright = require(mod); return _playwright; } catch {}
|
|
30
|
+
}
|
|
31
|
+
_playwright = false;
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function withTimeout(promise, ms) {
|
|
36
|
+
let timer;
|
|
37
|
+
const timeout = new Promise((_, reject) => {
|
|
38
|
+
timer = setTimeout(() => reject(new Error('browser action timed out after ' + ms + 'ms')), ms);
|
|
39
|
+
});
|
|
40
|
+
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function checkUrl(url) {
|
|
44
|
+
const u = String(url || '').trim();
|
|
45
|
+
if (!u) return { error: 'goto needs a url.' };
|
|
46
|
+
let parsed;
|
|
47
|
+
try { parsed = new URL(u); } catch { return { error: 'Invalid url: ' + u }; }
|
|
48
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
49
|
+
return { error: 'Only http/https urls are allowed (got ' + parsed.protocol + ').' };
|
|
50
|
+
}
|
|
51
|
+
return { url: parsed.href };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function pageSnapshot(page) {
|
|
55
|
+
const title = await page.title().catch(() => '');
|
|
56
|
+
return 'URL: ' + page.url() + (title ? '\nTitle: ' + title : '');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Launch (or reuse) the headless browser. Returns the page or {error}. */
|
|
60
|
+
async function ensurePage() {
|
|
61
|
+
if (_engine && _engine.page && !_engine.page.isClosed()) return _engine.page;
|
|
62
|
+
const pw = loadPlaywright();
|
|
63
|
+
if (!pw) {
|
|
64
|
+
return { error: 'No browser engine found. ' + INSTALL_HINT };
|
|
65
|
+
}
|
|
66
|
+
const attempts = [
|
|
67
|
+
() => pw.chromium.launch({ headless: true }),
|
|
68
|
+
() => pw.chromium.launch({ headless: true, channel: 'chrome' }),
|
|
69
|
+
() => pw.chromium.launch({ headless: true, channel: 'msedge' }),
|
|
70
|
+
];
|
|
71
|
+
let lastErr = null;
|
|
72
|
+
for (const attempt of attempts) {
|
|
73
|
+
try {
|
|
74
|
+
const browser = await withTimeout(attempt(), 20000);
|
|
75
|
+
const context = await browser.newContext({ viewport: { width: 1280, height: 800 } });
|
|
76
|
+
const page = await context.newPage();
|
|
77
|
+
_engine = { browser, page };
|
|
78
|
+
return page;
|
|
79
|
+
} catch (err) { lastErr = err; }
|
|
80
|
+
}
|
|
81
|
+
return { error: 'Failed to launch a browser: ' + (lastErr && lastErr.message) + '. ' + INSTALL_HINT };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Close the singleton browser (also used as the `close` action). */
|
|
85
|
+
async function closeBrowser() {
|
|
86
|
+
if (!_engine) return 'No browser is running.';
|
|
87
|
+
try { await _engine.browser.close(); } catch {}
|
|
88
|
+
_engine = null;
|
|
89
|
+
return 'Browser closed.';
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Execute one browser action. `params` comes straight from the tool call:
|
|
94
|
+
* action, url, selector, text, key, script, path
|
|
95
|
+
* @param {Record<string, any>} params
|
|
96
|
+
* @returns {Promise<string|{error: string}>}
|
|
97
|
+
*/
|
|
98
|
+
async function run(params) {
|
|
99
|
+
const action = String(params.action || '').toLowerCase().trim();
|
|
100
|
+
if (!ACTIONS.includes(action)) {
|
|
101
|
+
return { error: 'Unknown action "' + action + '". Use one of: ' + ACTIONS.join(' | ') };
|
|
102
|
+
}
|
|
103
|
+
if (action === 'close') return closeBrowser();
|
|
104
|
+
|
|
105
|
+
// Validate inputs BEFORE launching a browser so bad calls fail fast and
|
|
106
|
+
// never spawn a headless process.
|
|
107
|
+
if (action === 'goto') {
|
|
108
|
+
const u = checkUrl(params.url);
|
|
109
|
+
if (u.error) return u;
|
|
110
|
+
}
|
|
111
|
+
if ((action === 'click' || action === 'fill') && !String(params.selector || '').trim()) {
|
|
112
|
+
return { error: action + ' needs a CSS selector.' };
|
|
113
|
+
}
|
|
114
|
+
if (action === 'press' && !String(params.key || '').trim()) {
|
|
115
|
+
return { error: 'press needs a key (e.g. Enter, Tab, Escape, Control+A).' };
|
|
116
|
+
}
|
|
117
|
+
if (action === 'eval' && !String(params.script || '').trim()) {
|
|
118
|
+
return { error: 'eval needs a JavaScript expression (runs in the page context).' };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const page = await ensurePage();
|
|
122
|
+
if (page && typeof page === 'object' && 'error' in page) return page;
|
|
123
|
+
|
|
124
|
+
try {
|
|
125
|
+
switch (action) {
|
|
126
|
+
case 'goto': {
|
|
127
|
+
const u = checkUrl(params.url);
|
|
128
|
+
if (u.error) return u;
|
|
129
|
+
await withTimeout(page.goto(u.url, { waitUntil: 'domcontentloaded', timeout: ACTION_TIMEOUT_MS }), ACTION_TIMEOUT_MS + 2000);
|
|
130
|
+
await page.waitForLoadState('load', { timeout: 10000 }).catch(() => {});
|
|
131
|
+
return 'Opened ' + (await pageSnapshot(page));
|
|
132
|
+
}
|
|
133
|
+
case 'click': {
|
|
134
|
+
const sel = String(params.selector || '').trim();
|
|
135
|
+
if (!sel) return { error: 'click needs a CSS selector.' };
|
|
136
|
+
await withTimeout(page.click(sel, { timeout: 10000 }), 12000);
|
|
137
|
+
await page.waitForLoadState('domcontentloaded', { timeout: 5000 }).catch(() => {});
|
|
138
|
+
return 'Clicked "' + sel + '".\n' + (await pageSnapshot(page));
|
|
139
|
+
}
|
|
140
|
+
case 'fill': {
|
|
141
|
+
const sel = String(params.selector || '').trim();
|
|
142
|
+
if (!sel) return { error: 'fill needs a CSS selector.' };
|
|
143
|
+
await withTimeout(page.fill(sel, String(params.text ?? ''), { timeout: 10000 }), 12000);
|
|
144
|
+
return 'Filled "' + sel + '" with ' + String(params.text ?? '').length + ' chars.';
|
|
145
|
+
}
|
|
146
|
+
case 'press': {
|
|
147
|
+
const key = String(params.key || '').trim();
|
|
148
|
+
if (!key) return { error: 'press needs a key (e.g. Enter, Tab, Escape, Control+A).' };
|
|
149
|
+
await page.keyboard.press(key);
|
|
150
|
+
await page.waitForLoadState('domcontentloaded', { timeout: 5000 }).catch(() => {});
|
|
151
|
+
return 'Pressed ' + key + '.\n' + (await pageSnapshot(page));
|
|
152
|
+
}
|
|
153
|
+
case 'text': {
|
|
154
|
+
const body = await withTimeout(page.innerText('body').catch(() => ''), 10000);
|
|
155
|
+
const text = String(body || '').replace(/\n{3,}/g, '\n\n').trim();
|
|
156
|
+
const head = await pageSnapshot(page);
|
|
157
|
+
return head + '\n\n' + (text ? text.slice(0, 10000) : '(page has no visible text — try action="eval" or action="links")');
|
|
158
|
+
}
|
|
159
|
+
case 'eval': {
|
|
160
|
+
const script = String(params.script || '').trim();
|
|
161
|
+
if (!script) return { error: 'eval needs a JavaScript expression (runs in the page context).' };
|
|
162
|
+
// Function-body form keeps multi-statement scripts working.
|
|
163
|
+
const result = await withTimeout(page.evaluate((src) => {
|
|
164
|
+
try { return JSON.stringify({ ok: true, value: new Function('return (' + src + ')')() }); }
|
|
165
|
+
catch (e) { return JSON.stringify({ ok: false, error: String(e && e.message || e) }); }
|
|
166
|
+
}, script), 10000);
|
|
167
|
+
const parsed = JSON.parse(String(result));
|
|
168
|
+
if (!parsed.ok) return { error: 'eval failed: ' + parsed.error };
|
|
169
|
+
const out = typeof parsed.value === 'string' ? parsed.value : JSON.stringify(parsed.value, null, 2);
|
|
170
|
+
return String(out == null ? 'null' : out).slice(0, 10000);
|
|
171
|
+
}
|
|
172
|
+
case 'links': {
|
|
173
|
+
const links = await withTimeout(
|
|
174
|
+
page.$$eval('a[href]', (els) => els.slice(0, 100).map((a) => ({
|
|
175
|
+
href: a.getAttribute('href') || '',
|
|
176
|
+
text: (a.textContent || '').trim().replace(/\s+/g, ' ').slice(0, 120),
|
|
177
|
+
}))),
|
|
178
|
+
10000,
|
|
179
|
+
);
|
|
180
|
+
if (!links.length) return (await pageSnapshot(page)) + '\n\n(no links found)';
|
|
181
|
+
return links.map((l, i) => (i + 1) + '. ' + l.text + ' — ' + l.href).join('\n').slice(0, 10000);
|
|
182
|
+
}
|
|
183
|
+
case 'screenshot': {
|
|
184
|
+
const dest = params.path
|
|
185
|
+
? path.resolve(String(params.path))
|
|
186
|
+
: path.join(os.tmpdir(), 'loom-browser-' + Date.now() + '.png');
|
|
187
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
188
|
+
await withTimeout(page.screenshot({ path: dest, fullPage: false }), 15000);
|
|
189
|
+
return 'Screenshot saved: ' + dest + '\n' + (await pageSnapshot(page));
|
|
190
|
+
}
|
|
191
|
+
default:
|
|
192
|
+
return { error: 'Unknown action "' + action + '".' };
|
|
193
|
+
}
|
|
194
|
+
} catch (err) {
|
|
195
|
+
return { error: 'browser ' + action + ' failed: ' + (err && err.message || err) };
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Test/diagnostic hook — true when a browser page is live.
|
|
200
|
+
function isRunning() {
|
|
201
|
+
return !!( _engine && _engine.page && !_engine.page.isClosed() );
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
module.exports = { run, closeBrowser, isRunning, ACTIONS };
|
package/src/tools/index.js
CHANGED
|
@@ -5,6 +5,7 @@ const { execSync, spawn } = require('child_process');
|
|
|
5
5
|
const { glob: globLib } = require('glob');
|
|
6
6
|
const { commandRiskLabel } = require('../core/permissions');
|
|
7
7
|
const { loadConfig } = require('../config/settings');
|
|
8
|
+
const dnsPromises = require('dns').promises;
|
|
8
9
|
|
|
9
10
|
const cwd = process.cwd();
|
|
10
11
|
|
|
@@ -15,6 +16,74 @@ function globIgnore(full) {
|
|
|
15
16
|
return ['node_modules', '.git'].map((n) => path.posix.join(base, '**', n, '**'));
|
|
16
17
|
}
|
|
17
18
|
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
// SSRF guard (webfetch)
|
|
21
|
+
//
|
|
22
|
+
// The agent model decides which URLs webfetch opens, and prompt-injected page
|
|
23
|
+
// content can steer it toward "check http://169.254.169.254/latest/meta-data"
|
|
24
|
+
// or "read localhost:5984/_config". Those must fail closed:
|
|
25
|
+
// - only http/https, never file:/ftp:/data: transport tricks
|
|
26
|
+
// - loopback, link-local (cloud metadata!), RFC1918 private ranges, CGNAT,
|
|
27
|
+
// IPv6 ULA/link-local and IPv4-mapped addresses are refused
|
|
28
|
+
// - hostnames are resolved BEFORE connecting so a DNS name pointing into
|
|
29
|
+
// internal space is caught (checking the literal hostname is not enough)
|
|
30
|
+
// - redirects are followed manually and every hop re-checked, because a
|
|
31
|
+
// public URL can bounce straight at an internal one
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
/**
|
|
34
|
+
* True when an IPv4/IPv6 address string falls in a range the agent must never
|
|
35
|
+
* fetch (loopback, link-local/metadata, private, CGNAT, malformed).
|
|
36
|
+
* @param {string} ip
|
|
37
|
+
* @returns {boolean}
|
|
38
|
+
*/
|
|
39
|
+
function isPrivateAddress(ip) {
|
|
40
|
+
const s = String(ip || '').toLowerCase();
|
|
41
|
+
// IPv6 handling first: exact forms and prefix families we care about.
|
|
42
|
+
if (s.includes(':')) {
|
|
43
|
+
let bare = s.replace(/^\[|\]$/g, '');
|
|
44
|
+
if (bare === '::' || bare === '::1') return true;
|
|
45
|
+
const mapped = /^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/.exec(bare);
|
|
46
|
+
if (mapped) return isPrivateAddress(mapped[1]);
|
|
47
|
+
if (/^f[cd][0-9a-f]{2}:/.test(bare)) return true; // fc00::/7 ULA
|
|
48
|
+
if (/^fe[89ab][0-9a-f]:/.test(bare)) return true; // fe80::/10 link-local
|
|
49
|
+
if (/^2001:db8:/.test(bare)) return true; // documentation
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(s);
|
|
53
|
+
if (!m) return false;
|
|
54
|
+
const o = m.slice(1).map(Number);
|
|
55
|
+
if (o.some((x) => x > 255)) return true; // malformed → refuse
|
|
56
|
+
const [a, b] = o;
|
|
57
|
+
return (
|
|
58
|
+
a === 0 || // this-network
|
|
59
|
+
a === 10 || // RFC1918
|
|
60
|
+
a === 127 || // loopback
|
|
61
|
+
(a === 100 && b >= 64 && b <= 127) || // CGNAT 100.64/10
|
|
62
|
+
(a === 169 && b === 254) || // link-local / metadata
|
|
63
|
+
(a === 172 && b >= 16 && b <= 31) || // RFC1918
|
|
64
|
+
(a === 192 && b === 168) // RFC1918
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Resolve a URL's host and report whether every resolved address is public.
|
|
70
|
+
* Unresolvable hosts fail closed (false).
|
|
71
|
+
* @param {URL} u
|
|
72
|
+
* @returns {Promise<boolean>}
|
|
73
|
+
*/
|
|
74
|
+
async function urlIsPublic(u) {
|
|
75
|
+
const host = u.hostname.toLowerCase().replace(/^\[|\]$/g, '');
|
|
76
|
+
if (!host || host === 'localhost' || host.endsWith('.localhost')) return false;
|
|
77
|
+
if (isPrivateAddress(host)) return false;
|
|
78
|
+
let addrs;
|
|
79
|
+
try {
|
|
80
|
+
addrs = /** @type {{address: string}[]} */ (await dnsPromises.lookup(host, { all: true }));
|
|
81
|
+
} catch {
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
return addrs.length > 0 && addrs.every((a) => !isPrivateAddress(a.address));
|
|
85
|
+
}
|
|
86
|
+
|
|
18
87
|
const MODES = ['build', 'plan', 'chat'];
|
|
19
88
|
|
|
20
89
|
// Tools that never mutate the filesystem/state — safe to expose in plan mode.
|
|
@@ -251,10 +320,36 @@ const TOOLS = {
|
|
|
251
320
|
url: { type: 'string', required: true, description: 'URL to fetch' },
|
|
252
321
|
},
|
|
253
322
|
async execute(params) {
|
|
323
|
+
let u;
|
|
324
|
+
try {
|
|
325
|
+
u = new URL(String(params.url || ''));
|
|
326
|
+
} catch {
|
|
327
|
+
return { error: 'Invalid URL' };
|
|
328
|
+
}
|
|
329
|
+
if (u.protocol !== 'http:' && u.protocol !== 'https:') {
|
|
330
|
+
return { error: 'Blocked by webfetch policy: only http/https URLs are allowed' };
|
|
331
|
+
}
|
|
254
332
|
try {
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
333
|
+
// Follow redirects manually so every hop passes the SSRF guard — a
|
|
334
|
+
// public URL may legitimately bounce into an internal address.
|
|
335
|
+
let current = u;
|
|
336
|
+
const signal = AbortSignal.timeout(15000);
|
|
337
|
+
for (let hop = 0; hop < 5; hop++) {
|
|
338
|
+
if (!(await urlIsPublic(current))) {
|
|
339
|
+
return { error: `Blocked by webfetch policy: ${current.hostname} resolves to a private/reserved address (SSRF guard)` };
|
|
340
|
+
}
|
|
341
|
+
const resp = await fetch(current.toString(), { redirect: 'manual', signal });
|
|
342
|
+
if ([301, 302, 303, 307, 308].includes(resp.status)) {
|
|
343
|
+
const loc = resp.headers.get('location');
|
|
344
|
+
try { resp.body && resp.body.cancel(); } catch {}
|
|
345
|
+
if (!loc) return { error: `Fetch failed: redirect ${resp.status} without Location header` };
|
|
346
|
+
current = new URL(loc, current);
|
|
347
|
+
continue;
|
|
348
|
+
}
|
|
349
|
+
const text = await resp.text();
|
|
350
|
+
return text.slice(0, 10000);
|
|
351
|
+
}
|
|
352
|
+
return { error: 'Fetch failed: too many redirects' };
|
|
258
353
|
} catch (e) {
|
|
259
354
|
return { error: `Fetch failed: ${e.message}` };
|
|
260
355
|
}
|
|
@@ -345,7 +440,7 @@ const TOOLS = {
|
|
|
345
440
|
},
|
|
346
441
|
task: {
|
|
347
442
|
name: 'task',
|
|
348
|
-
description: 'Delegate a self-contained subtask to a subagent that runs fully autonomously and returns its final answer. Use when a piece of work can run independently (parallelizable), needs a focused toolset, or benefits from a different model.
|
|
443
|
+
description: 'Delegate a self-contained subtask to a subagent that runs fully autonomously and returns its final answer. Use when a piece of work can run independently (parallelizable), needs a focused toolset, or benefits from a different model. Agents: general (full toolset, default), explore (fast read-only code search), scout (quick docs lookups), research (deep multi-source investigation with a sourced report), coder (isolated implementation packages), reviewer (severity-ranked code review), tester (write/run tests until green), web (headless-browser work on live pages). Pick the smallest suitable agent. Pass a COMPLETE, self-contained instruction: the goal, relevant file paths, and exactly what to return. The subagent may run tools autonomously; fold its findings into your own answer afterwards.',
|
|
349
444
|
parameters: {
|
|
350
445
|
prompt: { type: 'string', required: true, description: 'Complete instructions for the subagent — goal, relevant file paths, and the exact output to return' },
|
|
351
446
|
agent: { type: 'string', required: false, description: 'Subagent id: general (default), explore, scout, or a custom one from config.agents' },
|
|
@@ -367,6 +462,22 @@ const TOOLS = {
|
|
|
367
462
|
return usage + '\n\n' + res.content;
|
|
368
463
|
}
|
|
369
464
|
},
|
|
465
|
+
browser: {
|
|
466
|
+
name: 'browser',
|
|
467
|
+
description: 'Drive a real headless browser (Playwright). Actions: goto (open an http/https url), click (CSS selector), fill (selector + text), press (key like Enter), text (visible page text + title/url), eval (JS expression in the page context), links (list page links), screenshot (save PNG, returns the file path), close. The browser stays open between calls so you can navigate, interact, and re-read the page. Prefer this over webfetch when the page needs JavaScript rendering or interaction.',
|
|
468
|
+
parameters: {
|
|
469
|
+
action: { type: 'string', required: true, description: '"goto" | "click" | "fill" | "press" | "text" | "eval" | "links" | "screenshot" | "close"' },
|
|
470
|
+
url: { type: 'string', required: false, description: 'Page url (action=goto)' },
|
|
471
|
+
selector: { type: 'string', required: false, description: 'CSS selector (action=click or fill)' },
|
|
472
|
+
text: { type: 'string', required: false, description: 'Text to type into the selector (action=fill)' },
|
|
473
|
+
key: { type: 'string', required: false, description: 'Key to press, e.g. Enter (action=press)' },
|
|
474
|
+
script: { type: 'string', required: false, description: 'JS expression evaluated in the page (action=eval)' },
|
|
475
|
+
path: { type: 'string', required: false, description: 'Screenshot destination path (action=screenshot; default: OS temp dir)' },
|
|
476
|
+
},
|
|
477
|
+
async execute(params) {
|
|
478
|
+
return require('./browser').run(params);
|
|
479
|
+
}
|
|
480
|
+
},
|
|
370
481
|
mcp: {
|
|
371
482
|
name: 'mcp',
|
|
372
483
|
description: 'Manage MCP (tool) servers for this workspace. Use when the user asks to add/remove/enable/disable an MCP server or connector (e.g. github, filesystem, gmail) or when a task requires tools the session does not have yet. In the TUI, hosting/cloud services (supabase, vercel, nextjs, railway, netlify, cloudflare) live under /connectors; dev-tool MCP servers (playwright, github, sentry, figma, postgres, mongodb, linear, slack, brave-search, exa, context7, filesystem, sqlite, puppeteer) live under /mcp. Common packages: Playwright=@playwright/mcp, Sentry=@sentry/mcp-server (needs --access-token TOKEN), Figma=figma-developer-mcp (env FIGMA_API_KEY), Postgres=@modelcontextprotocol/server-postgres (env DATABASE_URL), MongoDB=mongodb-mcp-server (env MONGODB_CONNECTION_STRING), Linear=linear-mcp-server (env LINEAR_API_KEY), Slack=@modelcontextprotocol/server-slack (env SLACK_BOT_TOKEN + SLACK_TEAM_ID), Brave=@modelcontextprotocol/server-brave-search (env BRAVE_API_KEY), Exa=exa-mcp-server (env EXA_API_KEY), Context7=@upstash/context7-mcp, Vercel=vercel-mcp-server (env VERCEL_TOKEN), Railway=@railway/mcp-server (env RAILWAY_API_TOKEN), Netlify=netlify-mcp-server (env NETLIFY_AUTH_TOKEN), Cloudflare=@cloudflare/mcp-server-cloudflare (env CLOUDFLARE_API_TOKEN + CLOUDFLARE_ACCOUNT_ID), Supabase=@supabase/mcp-server-supabase (needs --access-token TOKEN), GitHub=ghcr.io/github/github-mcp-server (docker). If unsure about an MCP package, verify it exists first with webfetch (e.g. https://registry.npmjs.org/<pkg>) or web search (fetch or bash curl) — never trust an unverified npm package.',
|
|
@@ -540,4 +651,4 @@ async function getAllToolDefinitions(mode = 'build') {
|
|
|
540
651
|
}
|
|
541
652
|
}
|
|
542
653
|
|
|
543
|
-
module.exports = { TOOLS, MODES, READ_ONLY_TOOLS, getToolDefinitions, getAllToolDefinitions, executeTool };
|
|
654
|
+
module.exports = { TOOLS, MODES, READ_ONLY_TOOLS, getToolDefinitions, getAllToolDefinitions, executeTool, isPrivateAddress };
|
package/src/tui/App.tsx
CHANGED
|
@@ -464,12 +464,27 @@ export function App(props: { initialPrompt?: string; resumeSession?: string; aut
|
|
|
464
464
|
// Keep the partial text in the bubble; the session already stored it,
|
|
465
465
|
// so the user can type "continue" to resume the task.
|
|
466
466
|
patchMessageAt(idx, { thinking: false, interrupted: true, thinkTime: Date.now() - t0, isError: false, parts: snapshotParts() });
|
|
467
|
-
|
|
467
|
+
var keptChars = String(resp.content || "").length;
|
|
468
|
+
var inProgress = null;
|
|
469
|
+
var tdNow = sess.todos || [];
|
|
470
|
+
for (var ti = 0; ti < tdNow.length; ti++) { if (tdNow[ti].status === "in_progress") { inProgress = tdNow[ti]; break; } }
|
|
471
|
+
appendMessage({ role: "system", content: "Interrupted \u2014 kept " + keptChars + " chars of the partial response" + (inProgress ? " (in progress: " + inProgress.content + ")" : "") + ". Type \"continue\" to resume the task." });
|
|
468
472
|
} else {
|
|
469
|
-
patchMessageAt(idx, { content: resp.content || "(
|
|
473
|
+
patchMessageAt(idx, { content: resp.content || "(the model ended the turn without any text)", thinking: false, thinkTime: Date.now() - t0, isError: isErr, parts: snapshotParts() });
|
|
474
|
+
// Real leftovers, not a generic stop line: when the turn wrote todos
|
|
475
|
+
// and still has open ones, the session reports exactly which.
|
|
476
|
+
var openTodos = resp.openTodos;
|
|
477
|
+
if (!isErr && openTodos && openTodos.length) {
|
|
478
|
+
var openNames = [];
|
|
479
|
+
for (var oi = 0; oi < Math.min(openTodos.length, 8); oi++) openNames.push("- [" + openTodos[oi].status + "] " + openTodos[oi].content);
|
|
480
|
+
appendMessage({ role: "system", content: "Stopped with " + openTodos.length + " todo(s) still open:\n" + openNames.join("\n") + (openTodos.length > 8 ? "\n- \u2026" : "") + "\nSay \"continue\" to keep going." });
|
|
481
|
+
}
|
|
470
482
|
}
|
|
471
483
|
if (!isErr && sess.mode === "plan") {
|
|
472
|
-
|
|
484
|
+
var plannedSteps = 0;
|
|
485
|
+
var planTodos = sess.todos || [];
|
|
486
|
+
for (var pi = 0; pi < planTodos.length; pi++) { if (planTodos[pi].status !== "completed" && planTodos[pi].status !== "cancelled") plannedSteps++; }
|
|
487
|
+
appendMessage({ role: "system", content: "Plan complete \u2014 " + (plannedSteps ? plannedSteps + " step(s) planned. " : "") + "press Tab to switch to Build, then send \"go\" to execute." });
|
|
473
488
|
}
|
|
474
489
|
setSpeedStats({ live: null, last: sess.getSpeed().last });
|
|
475
490
|
}).catch(function(e) {
|
|
@@ -710,7 +725,7 @@ export function App(props: { initialPrompt?: string; resumeSession?: string; aut
|
|
|
710
725
|
fsx.mkdirSync(dir, { recursive: true });
|
|
711
726
|
const esc = (s: string) => String(s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
712
727
|
const body = messages().map((m: any) => `<div class="m ${esc(m.role)}"><b>${esc(m.role)}</b><pre>${esc(String(m.content ?? ""))}</pre></div>`).join("\n");
|
|
713
|
-
const html = `<!doctype html><meta charset="utf-8"><title>Loom session</title><style>body{background:#
|
|
728
|
+
const html = `<!doctype html><meta charset="utf-8"><title>Loom session</title><style>body{background:#171512;color:#f2ede4;font-family:ui-monospace,monospace;max-width:900px;margin:2rem auto;padding:0 1rem}.m{border-left:3px solid #463f36;padding:.25rem .75rem;margin:.6rem 0}.m.user{border-color:#a8c496}.m.assistant{border-color:#e8845c}.m.system{color:#a89d8d}pre{white-space:pre-wrap;font-family:inherit;margin:.2rem 0}</style><h1>Loom Code session</h1>${body}`;
|
|
714
729
|
const f = pathx.join(dir, (sessionId() || "session") + ".html");
|
|
715
730
|
fsx.writeFileSync(f, html);
|
|
716
731
|
showToast("Shared: " + f, "ok", 6000);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
// Top breadcrumb bar — project path, session info, model.
|
|
1
|
+
// Top breadcrumb bar — project path, session info, model. bgPanel surface
|
|
2
|
+
// plus a dim right-hand cluster; no frame (the TUI stays grid-line-free).
|
|
2
3
|
import { palette } from "../theme.ts";
|
|
3
|
-
import { providerName, modelName, sessionId, cwdShort,
|
|
4
|
+
import { providerName, modelName, sessionId, cwdShort, inputMode } from "../store.ts";
|
|
4
5
|
|
|
5
6
|
const MODE_LABELS: Record<string, string> = { build: "B", plan: "P", chat: "C" };
|
|
6
7
|
|
|
@@ -15,19 +16,19 @@ export function BreadcrumbBar() {
|
|
|
15
16
|
paddingX={1}
|
|
16
17
|
backgroundColor={ui.bgPanel}
|
|
17
18
|
>
|
|
18
|
-
<box flexDirection="row">
|
|
19
|
+
<box flexDirection="row" alignItems="center">
|
|
19
20
|
<text fg={ui.primary}>{"loom"}</text>
|
|
20
|
-
<text fg={ui.fgMuted}>{"
|
|
21
|
+
<text fg={ui.fgMuted}>{" · "}</text>
|
|
21
22
|
<text fg={ui.fg}>{cwdShort() || "~"}</text>
|
|
22
|
-
<text fg={ui.fgMuted}>{"
|
|
23
|
-
<text fg={ui.
|
|
24
|
-
<text fg={ui.fgMuted}>{"
|
|
23
|
+
<text fg={ui.fgMuted}>{" · "}</text>
|
|
24
|
+
<text fg={ui.fgMuted}>{sessionId().slice(0, 8)}</text>
|
|
25
|
+
<text fg={ui.fgMuted}>{" "}</text>
|
|
25
26
|
<text fg={modeColors[inputMode()] || ui.primary}>{"[" + (MODE_LABELS[inputMode()] || "B") + "]"}</text>
|
|
26
27
|
</box>
|
|
27
|
-
<box flexDirection="row">
|
|
28
|
+
<box flexDirection="row" alignItems="center">
|
|
28
29
|
<text fg={ui.secondary}>{providerName()}</text>
|
|
29
30
|
<text fg={ui.fgMuted}>{" / "}</text>
|
|
30
|
-
<text fg={ui.
|
|
31
|
+
<text fg={ui.fgDim}>{modelName()}</text>
|
|
31
32
|
</box>
|
|
32
33
|
</box>
|
|
33
34
|
);
|
|
@@ -441,8 +441,8 @@ function MsgBubble(props: { m: any; idx: number }) {
|
|
|
441
441
|
|
|
442
442
|
if (m.role === "system") {
|
|
443
443
|
return (
|
|
444
|
-
<box marginBottom={1} paddingX={2}>
|
|
445
|
-
<text fg={ui.fgMuted}>{m.content}</text>
|
|
444
|
+
<box marginBottom={1} paddingX={2} backgroundColor={ui.bgMsg}>
|
|
445
|
+
<text fg={ui.fgMuted}>{"· " + m.content}</text>
|
|
446
446
|
</box>
|
|
447
447
|
);
|
|
448
448
|
}
|
|
@@ -486,7 +486,7 @@ function MsgBubble(props: { m: any; idx: number }) {
|
|
|
486
486
|
{!todoShown && m.todos?.length ? <TodosBlock todos={m.todos} /> : null}
|
|
487
487
|
{!isThink ? (
|
|
488
488
|
<box flexDirection="row" paddingLeft={3} marginTop={1}>
|
|
489
|
-
<text fg={isErr ? ui.error : ui.
|
|
489
|
+
<text fg={isErr ? ui.error : ui.primary}>{"\u25A3 " + (agentTag || "Loom")}</text>
|
|
490
490
|
<text fg={ui.fgMuted}>
|
|
491
491
|
{(m.thinkTime ? " \u00B7 " + fmtMs(m.thinkTime) : "") + (isStop ? " \u00B7 interrupted" : "")}
|
|
492
492
|
</text>
|
|
@@ -201,17 +201,19 @@ export function InputBar() {
|
|
|
201
201
|
</text>
|
|
202
202
|
{slashWin().items.map((item, i) => {
|
|
203
203
|
const abs = slashWin().start + i;
|
|
204
|
+
const act = abs === autoIndex();
|
|
204
205
|
return (
|
|
205
206
|
<box
|
|
206
|
-
|
|
207
|
+
flexDirection="row" paddingY={0}
|
|
208
|
+
backgroundColor={act ? ui.bgPanelAlt : undefined}
|
|
207
209
|
onMouseDown={() => selectSuggestionAt(abs)}
|
|
208
210
|
onMouseUp={() => pickSuggestionAt(abs)}
|
|
209
211
|
>
|
|
210
|
-
<text fg={
|
|
211
|
-
{(
|
|
212
|
+
<text fg={act ? ui.primary : ui.fgDim}>
|
|
213
|
+
{(act ? "▸ " : " ") + item.label.split(" ")[0]}
|
|
212
214
|
</text>
|
|
213
215
|
<Show when={!!item.desc}>
|
|
214
|
-
<text fg={
|
|
216
|
+
<text fg={act ? ui.fg : ui.fgMuted}>
|
|
215
217
|
{" " + item.desc}
|
|
216
218
|
</text>
|
|
217
219
|
</Show>
|
|
@@ -230,15 +232,19 @@ export function InputBar() {
|
|
|
230
232
|
>
|
|
231
233
|
{fileWin().items.map((item, i) => {
|
|
232
234
|
const abs = fileWin().start + i;
|
|
235
|
+
const act = abs === autoIndex();
|
|
233
236
|
return (
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
+
// Box wrapper: text nodes ignore a direct backgroundColor prop.
|
|
238
|
+
<box
|
|
239
|
+
flexDirection="row"
|
|
240
|
+
backgroundColor={act ? ui.bgPanelAlt : undefined}
|
|
237
241
|
onMouseDown={() => selectSuggestionAt(abs)}
|
|
238
242
|
onMouseUp={() => pickSuggestionAt(abs)}
|
|
239
243
|
>
|
|
240
|
-
{
|
|
241
|
-
|
|
244
|
+
<text fg={act ? ui.primary : ui.fgDim}>
|
|
245
|
+
{(act ? "▸ " : " ") + item.label}
|
|
246
|
+
</text>
|
|
247
|
+
</box>
|
|
242
248
|
);
|
|
243
249
|
})}
|
|
244
250
|
<text fg={ui.fgMuted}>{" UP/DN TAB/ENTER pick wheel/click ESC close" + rangeHint(fileWin().start, fileWin().total)}</text>
|
|
@@ -271,8 +277,13 @@ export function InputBar() {
|
|
|
271
277
|
<box flexGrow={1} flexDirection="column" justifyContent="center">
|
|
272
278
|
<text>
|
|
273
279
|
{/* @ts-ignore OpenTUI text nodes take fg/bg via the style prop (TextNodeOptions), not as direct props */}
|
|
274
|
-
<span style={{ fg: showCursor() ? ui.
|
|
275
|
-
<span style={{ fg: ui.fgMuted }}>{" Ask anything...
|
|
280
|
+
<span style={{ fg: showCursor() ? ui.fg : ui.bgInput }}>{"\u2588"}</span>
|
|
281
|
+
<span style={{ fg: ui.fgMuted }}>{" Ask anything... "}</span>
|
|
282
|
+
<span style={{ fg: ui.primary }}>{"/commands"}</span>
|
|
283
|
+
<span style={{ fg: ui.fgMuted }}>{" "}</span>
|
|
284
|
+
<span style={{ fg: ui.accent }}>{"@file"}</span>
|
|
285
|
+
<span style={{ fg: ui.fgMuted }}>{" "}</span>
|
|
286
|
+
<span style={{ fg: ui.warning }}>{"!shell"}</span>
|
|
276
287
|
</text>
|
|
277
288
|
</box>
|
|
278
289
|
}
|
|
@@ -57,7 +57,8 @@ function spansToNodes(spans: any[], keyPrefix: string) {
|
|
|
57
57
|
|
|
58
58
|
function HeadingText(props: { level: 1 | 2 | 3; spans: any[]; keyPrefix: string }) {
|
|
59
59
|
const marker = props.level === 1 ? "# " : props.level === 2 ? "## " : "### ";
|
|
60
|
-
|
|
60
|
+
const color = props.level === 1 ? ui.primary : props.level === 2 ? ui.primarySoft : ui.secondary;
|
|
61
|
+
return <text fg={color}>{marker}{spansToNodes(props.spans, props.keyPrefix)}</text>;
|
|
61
62
|
}
|
|
62
63
|
|
|
63
64
|
export function MdText(props: { md: string; wrap?: boolean }) {
|