@charzhu/openjaw-agent 0.2.9 → 0.3.0

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/dist/main.js CHANGED
@@ -2393,9 +2393,6 @@ var init_copilot = __esm({
2393
2393
  }
2394
2394
  async chat(options) {
2395
2395
  const modelInfo = await this.resolveModelInfo(this.config.model);
2396
- if (hasImageContent(options.messages) && modelInfo?.supportsVision === false) {
2397
- throw new Error(`model ${this.config.model} does not support image input; switch to a vision-capable model before submitting an image`);
2398
- }
2399
2396
  if (this.shouldRouteToResponses(modelInfo)) {
2400
2397
  return this.chatResponses(options, modelInfo);
2401
2398
  }
@@ -4008,7 +4005,7 @@ function categoryForTool(toolName) {
4008
4005
  if (toolName.startsWith("word_") || toolName.startsWith("excel_") || toolName.startsWith("powerpoint_") || toolName.startsWith("office_")) return "office";
4009
4006
  if (toolName.startsWith("memory_") || toolName === "todo_write") return "memory";
4010
4007
  if (toolName.startsWith("file_") || toolName.startsWith("image_") || toolName === "grep" || toolName === "glob") return "files";
4011
- if (toolName.startsWith("system_") || toolName.startsWith("clipboard_") || ["code_execute", "web_fetch", "web_search", "notify", "sleep", "ask_user", "config"].includes(toolName)) return "system";
4008
+ if (toolName.startsWith("system_") || toolName.startsWith("clipboard_") || ["code_execute", "web_fetch", "web_search", "web_extract", "notify", "sleep", "ask_user", "config"].includes(toolName)) return "system";
4012
4009
  return "mcp";
4013
4010
  }
4014
4011
  var DEFAULT_OPENAI_MAX_TOOLS, MCP_AUTO_GROW_HARD_CAP, BUILTIN_HEADROOM, FOUNDATION_TOOL_NAMES, PROFILE_CATEGORIES, CATEGORY_KEYWORDS;
@@ -4034,9 +4031,9 @@ var init_tool_exposure = __esm({
4034
4031
  CATEGORY_KEYWORDS = [
4035
4032
  { category: "email", patterns: [/\b(email|mail|outlook|inbox|calendar|schedule|meeting|invite|today|tomorrow)\b/i] },
4036
4033
  { category: "teams", patterns: [/\b(teams|chat|channel|message|dm|meeting|standup|today|mention)\b/i] },
4037
- { category: "browser", patterns: [/\b(browser|page|website|web|navigate|click|screenshot|search online)\b/i] },
4034
+ { category: "browser", patterns: [/\b(browser|page|website|web|navigate|click|screenshot|snapshot|console|image|search online)\b/i] },
4038
4035
  { category: "files", patterns: [/\b(file|folder|directory|read|write|edit|grep|glob|find in repo|codebase)\b/i] },
4039
- { category: "system", patterns: [/\b(shell|command|terminal|run|execute|clipboard|notify|sleep|web search|fetch url)\b/i] },
4036
+ { category: "system", patterns: [/\b(shell|command|terminal|run|execute|clipboard|notify|sleep|web search|fetch url|extract url|read url|article|docs?|paper|source page|news|latest|headlines|current events|breaking news)\b/i] },
4040
4037
  { category: "office", patterns: [/\b(word|excel|powerpoint|spreadsheet|document|presentation|slide)\b/i] },
4041
4038
  { category: "wechat", patterns: [/\b(wechat|weixin)\b/i] },
4042
4039
  { category: "memory", patterns: [/\b(memory|remember|recall|todo|preference)\b/i] }
@@ -5094,9 +5091,7 @@ ${summary}
5094
5091
  if (imageData) {
5095
5092
  const contentBlocks = [
5096
5093
  { type: "image", source: { type: "base64", media_type: imageData.mimeType, data: imageData.base64 } },
5097
- { type: "text", text: `${taskText}
5098
-
5099
- [ATTACHED IMAGE]: The image is attached as visual input in this message. Inspect it directly; do not search the filesystem for an uploaded image path unless the user explicitly asks for a file.` }
5094
+ { type: "text", text: taskText }
5100
5095
  ];
5101
5096
  this.conversationHistory.push({ role: "user", content: contentBlocks });
5102
5097
  } else {
@@ -5838,18 +5833,135 @@ var init_logger = __esm({
5838
5833
  }
5839
5834
  });
5840
5835
 
5836
+ // ../openjaw-mcp/dist/tools/url-safety.js
5837
+ import { isIP } from "node:net";
5838
+ function hasTokenLikeSecret(value) {
5839
+ const decoded = safeDecode(value);
5840
+ return TOKEN_PATTERNS.some((pattern) => pattern.test(value) || pattern.test(decoded));
5841
+ }
5842
+ function safeDecode(value) {
5843
+ try {
5844
+ return decodeURIComponent(value);
5845
+ } catch {
5846
+ return value;
5847
+ }
5848
+ }
5849
+ function normalizeHostname(hostname) {
5850
+ return hostname.replace(/^\[|\]$/g, "").replace(/\.+$/g, "").toLowerCase();
5851
+ }
5852
+ function isPrivateIPv4(host) {
5853
+ const parts = host.split(".").map((part) => Number(part));
5854
+ if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255))
5855
+ return false;
5856
+ const [a, b] = parts;
5857
+ return a === 10 || a === 172 && b >= 16 && b <= 31 || a === 192 && b === 168 || a === 127 || a === 169 && b === 254 || a === 0;
5858
+ }
5859
+ function ipv4FromMappedIPv6(host) {
5860
+ const lower = host.toLowerCase();
5861
+ const dotted = /^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/.exec(lower);
5862
+ if (dotted)
5863
+ return dotted[1];
5864
+ const hex = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/.exec(lower);
5865
+ if (!hex)
5866
+ return void 0;
5867
+ const high = Number.parseInt(hex[1], 16);
5868
+ const low = Number.parseInt(hex[2], 16);
5869
+ if (!Number.isFinite(high) || !Number.isFinite(low))
5870
+ return void 0;
5871
+ return [high >> 8, high & 255, low >> 8, low & 255].join(".");
5872
+ }
5873
+ function isPrivateIPv6(host) {
5874
+ const lower = host.toLowerCase();
5875
+ const mapped = ipv4FromMappedIPv6(lower);
5876
+ if (mapped)
5877
+ return isPrivateIPv4(mapped);
5878
+ return lower === "::1" || lower.startsWith("fc") || lower.startsWith("fd") || lower.startsWith("fe80:");
5879
+ }
5880
+ function isMetadataUrl(input) {
5881
+ const url = typeof input === "string" ? new URL(input) : input;
5882
+ const host = normalizeHostname(url.hostname);
5883
+ const mapped = ipv4FromMappedIPv6(host);
5884
+ return METADATA_HOSTS.has(host) || METADATA_IPS.has(host) || (mapped ? METADATA_IPS.has(mapped) : false);
5885
+ }
5886
+ function isPrivateHost(hostname) {
5887
+ const host = normalizeHostname(hostname);
5888
+ if (host === "localhost" || host.endsWith(".localhost"))
5889
+ return true;
5890
+ const ipKind = isIP(host);
5891
+ if (ipKind === 4)
5892
+ return isPrivateIPv4(host);
5893
+ if (ipKind === 6)
5894
+ return isPrivateIPv6(host);
5895
+ return false;
5896
+ }
5897
+ function validateHttpUrl(input, options = {}) {
5898
+ let url;
5899
+ try {
5900
+ url = new URL(input);
5901
+ } catch {
5902
+ return { ok: false, error: "Invalid URL" };
5903
+ }
5904
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
5905
+ return { ok: false, error: "Only http:// and https:// URLs are supported" };
5906
+ }
5907
+ if (url.username || url.password) {
5908
+ return { ok: false, error: "Blocked: URL contains embedded credentials" };
5909
+ }
5910
+ if (hasTokenLikeSecret(url.href)) {
5911
+ return { ok: false, error: "Blocked: URL contains what appears to be an API key, token, password, or secret" };
5912
+ }
5913
+ if (isMetadataUrl(url)) {
5914
+ return { ok: false, error: "Blocked: URL targets a cloud metadata endpoint" };
5915
+ }
5916
+ if (!options.allowPrivate && isPrivateHost(url.hostname)) {
5917
+ return { ok: false, error: "Blocked: URL targets a private, loopback, or internal network address" };
5918
+ }
5919
+ return { ok: true, url };
5920
+ }
5921
+ var TOKEN_PATTERNS, METADATA_HOSTS, METADATA_IPS;
5922
+ var init_url_safety = __esm({
5923
+ "../openjaw-mcp/dist/tools/url-safety.js"() {
5924
+ "use strict";
5925
+ TOKEN_PATTERNS = [
5926
+ /\b(?:sk|ghp|github_pat|gho|ghu|ghs|glpat|xox[baprs]|ya29|AIza)[A-Za-z0-9_\-]{12,}\b/i,
5927
+ /(?:api[_-]?key|access[_-]?token|auth[_-]?token|bearer|secret|password|passwd|pwd|token)=([^&\s]{8,})/i
5928
+ ];
5929
+ METADATA_HOSTS = /* @__PURE__ */ new Set([
5930
+ "metadata.google.internal",
5931
+ "metadata.azure.internal"
5932
+ ]);
5933
+ METADATA_IPS = /* @__PURE__ */ new Set([
5934
+ "169.254.169.254",
5935
+ "100.100.100.200"
5936
+ ]);
5937
+ __name(hasTokenLikeSecret, "hasTokenLikeSecret");
5938
+ __name(safeDecode, "safeDecode");
5939
+ __name(normalizeHostname, "normalizeHostname");
5940
+ __name(isPrivateIPv4, "isPrivateIPv4");
5941
+ __name(ipv4FromMappedIPv6, "ipv4FromMappedIPv6");
5942
+ __name(isPrivateIPv6, "isPrivateIPv6");
5943
+ __name(isMetadataUrl, "isMetadataUrl");
5944
+ __name(isPrivateHost, "isPrivateHost");
5945
+ __name(validateHttpUrl, "validateHttpUrl");
5946
+ }
5947
+ });
5948
+
5841
5949
  // ../openjaw-mcp/dist/channels/browser.js
5842
5950
  import * as chromeLauncher from "chrome-launcher";
5843
5951
  import CDP from "chrome-remote-interface";
5844
5952
  function escapeForJs(str) {
5845
5953
  return str.replace(/\\/g, "\\\\").replace(/'/g, "\\'").replace(/"/g, '\\"').replace(/`/g, "\\`").replace(/\$/g, "\\$").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\0/g, "\\0").replace(/</g, "\\x3c").replace(/>/g, "\\x3e");
5846
5954
  }
5847
- var BrowserChannel;
5955
+ var MAX_CONSOLE_MESSAGES, MAX_JS_ERRORS, MAX_CONSOLE_TEXT, BrowserChannel;
5848
5956
  var init_browser = __esm({
5849
5957
  "../openjaw-mcp/dist/channels/browser.js"() {
5850
5958
  "use strict";
5851
5959
  init_logger();
5960
+ init_url_safety();
5852
5961
  __name(escapeForJs, "escapeForJs");
5962
+ MAX_CONSOLE_MESSAGES = 200;
5963
+ MAX_JS_ERRORS = 100;
5964
+ MAX_CONSOLE_TEXT = 4e3;
5853
5965
  BrowserChannel = class {
5854
5966
  static {
5855
5967
  __name(this, "BrowserChannel");
@@ -5859,6 +5971,10 @@ var init_browser = __esm({
5859
5971
  client = null;
5860
5972
  page = null;
5861
5973
  launchPromise = null;
5974
+ currentSnapshotId = null;
5975
+ currentRefs = /* @__PURE__ */ new Map();
5976
+ consoleBuffer = [];
5977
+ jsErrorBuffer = [];
5862
5978
  constructor(config) {
5863
5979
  this.config = config;
5864
5980
  }
@@ -5905,6 +6021,8 @@ var init_browser = __esm({
5905
6021
  this.client = null;
5906
6022
  this.page = null;
5907
6023
  this.launchPromise = null;
6024
+ this.consoleBuffer = [];
6025
+ this.jsErrorBuffer = [];
5908
6026
  logger_default.info("Browser connection state reset");
5909
6027
  }
5910
6028
  /**
@@ -5957,13 +6075,71 @@ var init_browser = __esm({
5957
6075
  DOM.enable(),
5958
6076
  Network.enable()
5959
6077
  ]);
6078
+ this.attachRuntimeEventBuffers(Runtime);
6079
+ await this.attachRequestSafetyInterception(this.client.Fetch);
5960
6080
  this.page = { Runtime, Page, DOM, Input };
5961
6081
  logger_default.info("Browser launched", { port: this.chrome.port });
5962
6082
  }
6083
+ async attachRequestSafetyInterception(Fetch) {
6084
+ const fetchDomain = Fetch;
6085
+ if (!fetchDomain?.enable || !fetchDomain.requestPaused || !fetchDomain.failRequest || !fetchDomain.continueRequest) {
6086
+ logger_default.debug("Browser Fetch domain unavailable; navigation safety falls back to pre/final URL checks");
6087
+ return;
6088
+ }
6089
+ fetchDomain.requestPaused((event) => {
6090
+ void (async () => {
6091
+ try {
6092
+ const url = new URL(event.request.url);
6093
+ if (url.protocol === "http:" || url.protocol === "https:") {
6094
+ const safety = validateHttpUrl(url.href, { allowPrivate: true });
6095
+ if (!safety.ok) {
6096
+ await fetchDomain.failRequest({ requestId: event.requestId, errorReason: "BlockedByClient" });
6097
+ return;
6098
+ }
6099
+ }
6100
+ await fetchDomain.continueRequest({ requestId: event.requestId });
6101
+ } catch {
6102
+ await fetchDomain.continueRequest({ requestId: event.requestId }).catch(() => void 0);
6103
+ }
6104
+ })();
6105
+ });
6106
+ await fetchDomain.enable({ patterns: [{ urlPattern: "*", requestStage: "Request" }] });
6107
+ }
6108
+ attachRuntimeEventBuffers(Runtime) {
6109
+ const runtime = Runtime;
6110
+ runtime.consoleAPICalled?.((event) => {
6111
+ const text = (event.args ?? []).map((arg) => String(arg.value ?? arg.description ?? "")).join(" ").slice(0, MAX_CONSOLE_TEXT);
6112
+ this.consoleBuffer.push({
6113
+ type: event.type ?? "log",
6114
+ text,
6115
+ timestamp: event.timestamp ?? Date.now()
6116
+ });
6117
+ if (this.consoleBuffer.length > MAX_CONSOLE_MESSAGES) {
6118
+ this.consoleBuffer.splice(0, this.consoleBuffer.length - MAX_CONSOLE_MESSAGES);
6119
+ }
6120
+ });
6121
+ runtime.exceptionThrown?.((event) => {
6122
+ const details = event.exceptionDetails ?? {};
6123
+ this.jsErrorBuffer.push({
6124
+ message: String(details.exception?.description ?? details.exception?.value ?? details.text ?? "JavaScript exception").slice(0, MAX_CONSOLE_TEXT),
6125
+ url: details.url,
6126
+ line: details.lineNumber,
6127
+ column: details.columnNumber,
6128
+ timestamp: event.timestamp ?? Date.now()
6129
+ });
6130
+ if (this.jsErrorBuffer.length > MAX_JS_ERRORS) {
6131
+ this.jsErrorBuffer.splice(0, this.jsErrorBuffer.length - MAX_JS_ERRORS);
6132
+ }
6133
+ });
6134
+ }
5963
6135
  async navigate(options) {
5964
6136
  await this.ensureBrowser();
5965
6137
  const { Page, Runtime } = this.page;
5966
- await Page.navigate({ url: options.url });
6138
+ const initialSafety = validateHttpUrl(options.url, { allowPrivate: true });
6139
+ if (!initialSafety.ok) {
6140
+ return { url: options.url, title: "", snapshot: "", error: initialSafety.error };
6141
+ }
6142
+ await Page.navigate({ url: initialSafety.url.href });
5967
6143
  if (options.waitFor === "load") {
5968
6144
  await Page.loadEventFired();
5969
6145
  } else if (options.waitFor === "domcontentloaded") {
@@ -5975,15 +6151,23 @@ var init_browser = __esm({
5975
6151
  const result = await Runtime.evaluate({
5976
6152
  expression: "document.title"
5977
6153
  });
5978
- return {
5979
- url: options.url,
5980
- title: result.result.value
5981
- };
6154
+ const url = await this.getCurrentUrl() ?? initialSafety.url.href;
6155
+ const finalSafety = validateHttpUrl(url, { allowPrivate: true });
6156
+ if (!finalSafety.ok) {
6157
+ return { url, title: result.result.value, snapshot: "", error: `Blocked final URL: ${finalSafety.error}` };
6158
+ }
6159
+ const title = result.result.value;
6160
+ const snapshot = await this.snapshot({ full: false });
6161
+ return { ...snapshot, url, title };
5982
6162
  }
5983
6163
  async click(options) {
5984
6164
  await this.ensureBrowser();
5985
6165
  const { Runtime } = this.page;
5986
- const findExpr = options.selector ? `document.querySelector('${escapeForJs(options.selector)}')` : options.text ? `(() => {
6166
+ const resolvedSelector = options.ref ? this.resolveRef(options.ref, options.snapshotId) : options.selector;
6167
+ if (options.ref && !resolvedSelector) {
6168
+ return { success: false, element: `stale or unknown ref ${options.ref}; call browser_snapshot again` };
6169
+ }
6170
+ const findExpr = resolvedSelector ? `document.querySelector('${escapeForJs(resolvedSelector)}')` : options.text ? `(() => {
5987
6171
  const isVis = (e) => {
5988
6172
  if (!e.offsetParent && e.tagName !== 'BODY' && e.tagName !== 'HTML') return false;
5989
6173
  const s = getComputedStyle(e);
@@ -6107,9 +6291,13 @@ var init_browser = __esm({
6107
6291
  async type(options) {
6108
6292
  await this.ensureBrowser();
6109
6293
  const { Runtime } = this.page;
6294
+ const resolvedSelector = options.ref ? this.resolveRef(options.ref, options.snapshotId) : options.selector;
6295
+ if (!resolvedSelector) {
6296
+ return { success: false, error: options.ref ? `stale or unknown ref ${options.ref}; call browser_snapshot again` : "No selector or ref provided" };
6297
+ }
6110
6298
  const script = `
6111
6299
  (() => {
6112
- const el = document.querySelector('${escapeForJs(options.selector)}');
6300
+ const el = document.querySelector('${escapeForJs(resolvedSelector)}');
6113
6301
  if (el) {
6114
6302
  ${options.clear ? "el.value = '';" : ""}
6115
6303
  el.value = '${escapeForJs(options.text)}';
@@ -6191,6 +6379,141 @@ var init_browser = __esm({
6191
6379
  const result = await Runtime.evaluate({ expression: script, returnByValue: true });
6192
6380
  return { content: result.result.value };
6193
6381
  }
6382
+ async snapshot(options) {
6383
+ await this.ensureBrowser();
6384
+ const { Runtime } = this.page;
6385
+ const scope = options?.selector ? `'${escapeForJs(options.selector)}'` : `'body'`;
6386
+ const maxElements = Math.min(Math.max(options?.maxElements ?? 80, 1), 200);
6387
+ const textLimit = options?.full ? 8e3 : 1200;
6388
+ const script = `(() => {
6389
+ const root = document.querySelector(${scope});
6390
+ const cssEscape = (value) => globalThis.CSS?.escape
6391
+ ? globalThis.CSS.escape(String(value))
6392
+ : String(value).replace(/[^a-zA-Z0-9_-]/g, ch => '\\\\' + ch.charCodeAt(0).toString(16) + ' ');
6393
+ const selectorFor = (el) => {
6394
+ if (el.id) return '#' + cssEscape(el.id);
6395
+ const parts = [];
6396
+ let cur = el;
6397
+ while (cur && cur.nodeType === 1 && cur !== document.body) {
6398
+ let part = cur.tagName.toLowerCase();
6399
+ const parent = cur.parentElement;
6400
+ if (!parent) break;
6401
+ const same = Array.from(parent.children).filter(child => child.tagName === cur.tagName);
6402
+ if (same.length > 1) part += ':nth-of-type(' + (same.indexOf(cur) + 1) + ')';
6403
+ parts.unshift(part);
6404
+ cur = parent;
6405
+ }
6406
+ return parts.length ? parts.join(' > ') : 'body';
6407
+ };
6408
+ const isVisible = (el) => {
6409
+ const rect = el.getBoundingClientRect();
6410
+ const style = getComputedStyle(el);
6411
+ return rect.width > 0 && rect.height > 0 && style.display !== 'none' && style.visibility !== 'hidden' && style.opacity !== '0';
6412
+ };
6413
+ const disabled = (el) => Boolean(el.disabled || el.getAttribute('aria-disabled') === 'true');
6414
+ if (!root) return JSON.stringify({ url: location.href, title: document.title, elements: [], text: '' });
6415
+ const nodes = Array.from(root.querySelectorAll('button, a[href], input, select, textarea, summary, [role="button"], [role="link"], [role="textbox"], [onclick], [tabindex]'));
6416
+ const elements = [];
6417
+ for (const el of nodes) {
6418
+ if (elements.length >= ${maxElements}) break;
6419
+ const tag = el.tagName.toLowerCase();
6420
+ const type = el.getAttribute('type') || '';
6421
+ const role = el.getAttribute('role') || (type ? tag + '[' + type + ']' : tag);
6422
+ const text = (el.innerText || el.textContent || '').replace(/s+/g, ' ').trim().slice(0, 120);
6423
+ const label = (el.getAttribute('aria-label') || el.getAttribute('title') || el.getAttribute('placeholder') || text || el.getAttribute('value') || '').replace(/s+/g, ' ').trim().slice(0, 120);
6424
+ elements.push({
6425
+ ref: '@e' + (elements.length + 1),
6426
+ selector: selectorFor(el),
6427
+ role,
6428
+ tag,
6429
+ label,
6430
+ text,
6431
+ visible: isVisible(el),
6432
+ disabled: disabled(el),
6433
+ });
6434
+ }
6435
+ const pageText = (root.innerText || root.textContent || '').replace(/s+/g, ' ').trim().slice(0, ${textLimit});
6436
+ return JSON.stringify({ url: location.href, title: document.title, elements, text: pageText });
6437
+ })()`;
6438
+ const result = await Runtime.evaluate({ expression: script, returnByValue: true });
6439
+ const parsed = JSON.parse(result.result.value || "{}");
6440
+ const snapshotId = `snap_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
6441
+ const elements = parsed.elements ?? [];
6442
+ const refs = {};
6443
+ this.currentRefs.clear();
6444
+ for (const element of elements) {
6445
+ refs[element.ref] = element.selector;
6446
+ this.currentRefs.set(element.ref, element.selector);
6447
+ }
6448
+ this.currentSnapshotId = snapshotId;
6449
+ const lines = [
6450
+ `URL: ${parsed.url ?? ""}`,
6451
+ `Title: ${parsed.title ?? ""}`,
6452
+ `Snapshot: ${snapshotId}`,
6453
+ "",
6454
+ `Interactive elements (${elements.length}):`,
6455
+ ...elements.map((element) => `${element.ref} <${element.role}>${element.visible ? "" : " [hidden]"}${element.disabled ? " [disabled]" : ""} ${JSON.stringify(element.label || element.text || element.selector)}`),
6456
+ "",
6457
+ "Visible text:",
6458
+ parsed.text ?? ""
6459
+ ];
6460
+ return {
6461
+ url: parsed.url ?? "",
6462
+ title: parsed.title ?? "",
6463
+ snapshot: lines.join("\n").trim(),
6464
+ snapshot_id: snapshotId,
6465
+ refs,
6466
+ elements,
6467
+ text: parsed.text ?? "",
6468
+ element_count: elements.length
6469
+ };
6470
+ }
6471
+ resolveRef(ref, snapshotId) {
6472
+ if (snapshotId && this.currentSnapshotId && snapshotId !== this.currentSnapshotId)
6473
+ return void 0;
6474
+ return this.currentRefs.get(ref);
6475
+ }
6476
+ async back() {
6477
+ await this.ensureBrowser();
6478
+ const { Page, Runtime } = this.page;
6479
+ const history = await Page.getNavigationHistory();
6480
+ if (history.currentIndex > 0) {
6481
+ await Page.navigateToHistoryEntry({ entryId: history.entries[history.currentIndex - 1].id });
6482
+ await Page.loadEventFired().catch(() => void 0);
6483
+ }
6484
+ const title = await Runtime.evaluate({ expression: "document.title", returnByValue: true });
6485
+ return { url: await this.getCurrentUrl(), title: title.result.value };
6486
+ }
6487
+ async press(key) {
6488
+ await this.sendKey(key);
6489
+ return { success: true, key };
6490
+ }
6491
+ async getImages() {
6492
+ await this.ensureBrowser();
6493
+ const { Runtime } = this.page;
6494
+ const script = `JSON.stringify(Array.from(document.images).slice(0, 100).map(img => {
6495
+ const rect = img.getBoundingClientRect();
6496
+ return { src: img.currentSrc || img.src, alt: img.alt || '', width: img.naturalWidth || rect.width || 0, height: img.naturalHeight || rect.height || 0, visible: rect.width > 0 && rect.height > 0 };
6497
+ }).filter(img => img.src && !img.src.startsWith('data:')))`;
6498
+ const result = await Runtime.evaluate({ expression: script, returnByValue: true });
6499
+ const images = JSON.parse(result.result.value || "[]");
6500
+ return { images, count: images.length };
6501
+ }
6502
+ async consoleMessages(clear2 = false) {
6503
+ const consoleMessages = [...this.consoleBuffer];
6504
+ const jsErrors = [...this.jsErrorBuffer];
6505
+ if (clear2) {
6506
+ this.consoleBuffer = [];
6507
+ this.jsErrorBuffer = [];
6508
+ }
6509
+ return {
6510
+ console_messages: consoleMessages,
6511
+ js_errors: jsErrors,
6512
+ total_messages: consoleMessages.length,
6513
+ total_errors: jsErrors.length,
6514
+ note: clear2 ? "Returned and cleared buffered console messages." : "Returned buffered console messages."
6515
+ };
6516
+ }
6194
6517
  async evaluate(script) {
6195
6518
  await this.ensureBrowser();
6196
6519
  const { Runtime } = this.page;
@@ -6625,18 +6948,48 @@ function createBrowseTools(config, sharedBrowser) {
6625
6948
  required: ["url"]
6626
6949
  },
6627
6950
  execute: /* @__PURE__ */ __name(async (input) => {
6951
+ const safety = validateHttpUrl(input.url, { allowPrivate: true });
6952
+ if (!safety.ok)
6953
+ return { error: safety.error, url: input.url };
6628
6954
  return await browser.navigate({
6629
6955
  url: input.url,
6630
6956
  waitFor: input.wait_for ?? "load"
6631
6957
  });
6632
6958
  }, "execute")
6633
6959
  },
6960
+ {
6961
+ name: "browser_snapshot",
6962
+ description: "Get a compact snapshot of the current page with stable refs like @e1 for browser_click and browser_type. Use after navigation or after interactions that changed the page.",
6963
+ parameters: {
6964
+ type: "object",
6965
+ properties: {
6966
+ full: { type: "boolean", description: "Include more visible page text (default false)", default: false },
6967
+ selector: { type: "string", description: "Optional CSS selector to scope the snapshot" },
6968
+ max_elements: { type: "number", description: "Maximum interactive elements to include (default 80, max 200)" }
6969
+ }
6970
+ },
6971
+ execute: /* @__PURE__ */ __name(async (input) => {
6972
+ return await browser.snapshot({
6973
+ full: input.full === true,
6974
+ selector: input.selector,
6975
+ maxElements: input.max_elements
6976
+ });
6977
+ }, "execute")
6978
+ },
6634
6979
  {
6635
6980
  name: "browser_click",
6636
- description: "Click an element on the page",
6981
+ description: "Click an element on the page. Prefer ref from browser_snapshot (e.g. @e5). Legacy selector/text targeting is still supported.",
6637
6982
  parameters: {
6638
6983
  type: "object",
6639
6984
  properties: {
6985
+ ref: {
6986
+ type: "string",
6987
+ description: "Element ref from browser_snapshot, e.g. @e5"
6988
+ },
6989
+ snapshot_id: {
6990
+ type: "string",
6991
+ description: "Optional snapshot_id associated with the ref. If stale, refresh with browser_snapshot."
6992
+ },
6640
6993
  selector: {
6641
6994
  type: "string",
6642
6995
  description: "CSS selector of element to click"
@@ -6649,6 +7002,8 @@ function createBrowseTools(config, sharedBrowser) {
6649
7002
  },
6650
7003
  execute: /* @__PURE__ */ __name(async (input) => {
6651
7004
  return await browser.click({
7005
+ ref: input.ref,
7006
+ snapshotId: input.snapshot_id,
6652
7007
  selector: input.selector,
6653
7008
  text: input.text
6654
7009
  });
@@ -6656,10 +7011,18 @@ function createBrowseTools(config, sharedBrowser) {
6656
7011
  },
6657
7012
  {
6658
7013
  name: "browser_type",
6659
- description: "Type text into an input element",
7014
+ description: "Type text into an input element. Prefer ref from browser_snapshot (e.g. @e3). Legacy selector targeting is still supported.",
6660
7015
  parameters: {
6661
7016
  type: "object",
6662
7017
  properties: {
7018
+ ref: {
7019
+ type: "string",
7020
+ description: "Element ref from browser_snapshot, e.g. @e3"
7021
+ },
7022
+ snapshot_id: {
7023
+ type: "string",
7024
+ description: "Optional snapshot_id associated with the ref. If stale, refresh with browser_snapshot."
7025
+ },
6663
7026
  selector: {
6664
7027
  type: "string",
6665
7028
  description: "CSS selector of input element"
@@ -6679,10 +7042,12 @@ function createBrowseTools(config, sharedBrowser) {
6679
7042
  default: false
6680
7043
  }
6681
7044
  },
6682
- required: ["selector", "text"]
7045
+ required: ["text"]
6683
7046
  },
6684
7047
  execute: /* @__PURE__ */ __name(async (input) => {
6685
7048
  return await browser.type({
7049
+ ref: input.ref,
7050
+ snapshotId: input.snapshot_id,
6686
7051
  selector: input.selector,
6687
7052
  text: input.text,
6688
7053
  clear: input.clear ?? true,
@@ -6690,6 +7055,24 @@ function createBrowseTools(config, sharedBrowser) {
6690
7055
  });
6691
7056
  }, "execute")
6692
7057
  },
7058
+ {
7059
+ name: "browser_back",
7060
+ description: "Navigate back to the previous page in browser history.",
7061
+ parameters: { type: "object", properties: {} },
7062
+ execute: /* @__PURE__ */ __name(async () => await browser.back(), "execute")
7063
+ },
7064
+ {
7065
+ name: "browser_press",
7066
+ description: "Press a keyboard key in the browser, e.g. Enter, Tab, Escape, ArrowDown.",
7067
+ parameters: {
7068
+ type: "object",
7069
+ properties: {
7070
+ key: { type: "string", description: "Key to press, e.g. Enter, Tab, Escape, ArrowDown" }
7071
+ },
7072
+ required: ["key"]
7073
+ },
7074
+ execute: /* @__PURE__ */ __name(async (input) => await browser.press(input.key), "execute")
7075
+ },
6693
7076
  {
6694
7077
  name: "browser_extract",
6695
7078
  description: "Extract text content from the page",
@@ -6734,6 +7117,51 @@ function createBrowseTools(config, sharedBrowser) {
6734
7117
  return await browser.evaluate(input.script);
6735
7118
  }, "execute")
6736
7119
  },
7120
+ {
7121
+ name: "browser_console",
7122
+ description: "Read browser console logs and JavaScript errors. This tool is read-only; use browser_evaluate for JavaScript execution.",
7123
+ parameters: {
7124
+ type: "object",
7125
+ properties: {
7126
+ clear: { type: "boolean", description: "Clear buffered logs after reading, if buffering is active", default: false }
7127
+ }
7128
+ },
7129
+ execute: /* @__PURE__ */ __name(async (input) => await browser.consoleMessages(input.clear === true), "execute")
7130
+ },
7131
+ {
7132
+ name: "browser_get_images",
7133
+ description: "List images on the current page with URLs, alt text, dimensions, and visibility. Use before visual analysis or downloading images.",
7134
+ parameters: { type: "object", properties: {} },
7135
+ execute: /* @__PURE__ */ __name(async () => await browser.getImages(), "execute")
7136
+ },
7137
+ {
7138
+ name: "browser_vision",
7139
+ description: "Take a browser screenshot for visual inspection. Returns a screenshot path plus page metadata. Use for visual layouts, CAPTCHAs, screenshots, or image-heavy pages.",
7140
+ parameters: {
7141
+ type: "object",
7142
+ properties: {
7143
+ question: { type: "string", description: "What you want to inspect visually" },
7144
+ annotate: { type: "boolean", description: "Reserved for future ref overlays; currently returns a normal screenshot", default: false }
7145
+ }
7146
+ },
7147
+ execute: /* @__PURE__ */ __name(async (input) => {
7148
+ const { join: join47 } = await import("node:path");
7149
+ const { tmpdir: tmpdir13 } = await import("node:os");
7150
+ const { randomUUID: randomUUID14 } = await import("node:crypto");
7151
+ const screenshotPath = join47(tmpdir13(), `openjaw-browser-${randomUUID14().slice(0, 8)}.png`);
7152
+ const screenshot = await browser.screenshot({ fullPage: false, path: screenshotPath });
7153
+ const snapshot = await browser.snapshot({ full: false });
7154
+ return {
7155
+ message: "Browser screenshot captured for visual inspection",
7156
+ question: input.question,
7157
+ snapshot_id: snapshot.snapshot_id,
7158
+ url: snapshot.url,
7159
+ title: snapshot.title,
7160
+ screenshotPath: screenshot.path ?? screenshotPath,
7161
+ imagePayload: "not_attached: screenshot path returned to avoid sending images to non-vision providers"
7162
+ };
7163
+ }, "execute")
7164
+ },
6737
7165
  {
6738
7166
  name: "browser_wait",
6739
7167
  description: "Wait for an element to appear, text to change, or element to hide. Use before interacting with dynamic/slow-loading pages.",
@@ -6819,6 +7247,7 @@ var init_browse = __esm({
6819
7247
  "../openjaw-mcp/dist/tools/browse.js"() {
6820
7248
  "use strict";
6821
7249
  init_browser();
7250
+ init_url_safety();
6822
7251
  __name(createBrowseTools, "createBrowseTools");
6823
7252
  }
6824
7253
  });
@@ -15750,6 +16179,175 @@ function urlMatchesDomain(url, domain) {
15750
16179
  return false;
15751
16180
  }
15752
16181
  }
16182
+ function normalizeSearchResults(results, limit) {
16183
+ return results.slice(0, limit).map((result, index) => ({
16184
+ ...result,
16185
+ snippet: result.snippet ?? "",
16186
+ description: result.snippet ?? "",
16187
+ position: index + 1
16188
+ }));
16189
+ }
16190
+ function clampNumber(value, fallback, min, max) {
16191
+ const numberValue = typeof value === "number" ? value : Number(value);
16192
+ if (!Number.isFinite(numberValue))
16193
+ return fallback;
16194
+ return Math.min(Math.max(Math.trunc(numberValue), min), max);
16195
+ }
16196
+ function extractHtmlTitle(html) {
16197
+ const match = /<title[^>]*>([\s\S]*?)<\/title>/i.exec(html);
16198
+ if (!match)
16199
+ return void 0;
16200
+ return DECODE_ENTITIES(match[1].replace(/\s+/g, " ").trim()) || void 0;
16201
+ }
16202
+ function cleanHtmlForMarkdown(html) {
16203
+ return html.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, "").replace(/<style[^>]*>[\s\S]*?<\/style>/gi, "").replace(/<noscript[^>]*>[\s\S]*?<\/noscript>/gi, "").replace(/<svg[^>]*>[\s\S]*?<\/svg>/gi, "").replace(/<nav[^>]*>[\s\S]*?<\/nav>/gi, "").replace(/<footer[^>]*>[\s\S]*?<\/footer>/gi, "").replace(/<header[^>]*>[\s\S]*?<\/header>/gi, "");
16204
+ }
16205
+ function htmlToText(html) {
16206
+ return DECODE_ENTITIES(cleanHtmlForMarkdown(html).replace(/<br\s*\/?>/gi, "\n").replace(/<\/p>/gi, "\n\n").replace(/<\/h[1-6]>/gi, "\n\n").replace(/<[^>]+>/g, " ").replace(/[ \t]+/g, " ").replace(/\n\s+/g, "\n").replace(/\n{3,}/g, "\n\n").trim());
16207
+ }
16208
+ function isPdfLike(url, contentType) {
16209
+ if (/application\/pdf/i.test(contentType))
16210
+ return true;
16211
+ try {
16212
+ return new URL(url).pathname.toLowerCase().endsWith(".pdf");
16213
+ } catch {
16214
+ return /\.pdf(?:$|[?#])/i.test(url);
16215
+ }
16216
+ }
16217
+ async function htmlToMarkdown(html) {
16218
+ try {
16219
+ const TurndownModule = await import("turndown");
16220
+ const TurndownService = TurndownModule.default || TurndownModule;
16221
+ const turndown = new TurndownService({ headingStyle: "atx", codeBlockStyle: "fenced" });
16222
+ return turndown.turndown(cleanHtmlForMarkdown(html));
16223
+ } catch {
16224
+ return cleanHtmlForMarkdown(html).replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
16225
+ }
16226
+ }
16227
+ function paginateContent(text, startIndex, maxLength) {
16228
+ const safeStart = clampNumber(startIndex, 0, 0, Math.max(0, text.length));
16229
+ const safeMax = clampNumber(maxLength, 8e3, 1, 1e5);
16230
+ const content = text.slice(safeStart, safeStart + safeMax);
16231
+ const nextStartIndex = safeStart + content.length < text.length ? safeStart + content.length : void 0;
16232
+ return {
16233
+ content,
16234
+ contentLength: text.length,
16235
+ truncated: nextStartIndex !== void 0,
16236
+ startIndex: safeStart,
16237
+ ...nextStartIndex !== void 0 && { nextStartIndex }
16238
+ };
16239
+ }
16240
+ async function fetchWithValidatedRedirects(startUrl, options) {
16241
+ let current = startUrl;
16242
+ const maxRedirects = options.maxRedirects ?? 10;
16243
+ for (let redirects = 0; redirects <= maxRedirects; redirects++) {
16244
+ const response = await fetch(current.href, {
16245
+ signal: options.signal,
16246
+ headers: options.headers,
16247
+ redirect: "manual"
16248
+ });
16249
+ if (response.status < 300 || response.status >= 400) {
16250
+ return { response, finalUrl: current.href };
16251
+ }
16252
+ const location = response.headers.get("location");
16253
+ if (!location) {
16254
+ return { response, finalUrl: current.href };
16255
+ }
16256
+ const nextUrl = new URL(location, current.href);
16257
+ const nextSafety = validateHttpUrl(nextUrl.href, { allowPrivate: options.allowPrivate });
16258
+ if (!nextSafety.ok) {
16259
+ return { finalUrl: nextUrl.href, error: `Blocked redirect: ${nextSafety.error}` };
16260
+ }
16261
+ current = nextSafety.url;
16262
+ }
16263
+ return { finalUrl: current.href, error: `Too many redirects (>${maxRedirects})` };
16264
+ }
16265
+ async function extractUrlContent(inputUrl, options) {
16266
+ const initial = validateHttpUrl(inputUrl, { allowPrivate: options.allowPrivate });
16267
+ if (!initial.ok) {
16268
+ return {
16269
+ url: inputUrl,
16270
+ finalUrl: inputUrl,
16271
+ contentType: "",
16272
+ content: "",
16273
+ contentLength: 0,
16274
+ truncated: false,
16275
+ startIndex: options.startIndex,
16276
+ error: initial.error
16277
+ };
16278
+ }
16279
+ const controller = new AbortController();
16280
+ const timeout = setTimeout(() => controller.abort(), options.timeoutMs ?? 2e4);
16281
+ try {
16282
+ const fetched = await fetchWithValidatedRedirects(initial.url, {
16283
+ signal: controller.signal,
16284
+ headers: { "User-Agent": "OpenJaw-Agent/1.0" },
16285
+ allowPrivate: options.allowPrivate
16286
+ });
16287
+ const finalUrl = fetched.finalUrl;
16288
+ if (fetched.error || !fetched.response) {
16289
+ return {
16290
+ url: inputUrl,
16291
+ finalUrl,
16292
+ contentType: "",
16293
+ content: "",
16294
+ contentLength: 0,
16295
+ truncated: false,
16296
+ startIndex: options.startIndex,
16297
+ error: fetched.error ?? "Fetch failed before response"
16298
+ };
16299
+ }
16300
+ const response = fetched.response;
16301
+ if (!response.ok) {
16302
+ return {
16303
+ url: inputUrl,
16304
+ finalUrl,
16305
+ contentType: response.headers.get("content-type") || "",
16306
+ content: "",
16307
+ contentLength: 0,
16308
+ truncated: false,
16309
+ startIndex: options.startIndex,
16310
+ error: `HTTP ${response.status}: ${response.statusText}`
16311
+ };
16312
+ }
16313
+ const contentType = response.headers.get("content-type") || "";
16314
+ if (isPdfLike(finalUrl, contentType)) {
16315
+ return {
16316
+ url: inputUrl,
16317
+ finalUrl,
16318
+ contentType,
16319
+ content: "",
16320
+ contentLength: 0,
16321
+ truncated: false,
16322
+ startIndex: options.startIndex,
16323
+ error: "PDF extraction is not yet supported"
16324
+ };
16325
+ }
16326
+ const rawText = await response.text();
16327
+ const title = contentType.includes("html") ? extractHtmlTitle(rawText) : void 0;
16328
+ const text = contentType.includes("html") ? options.format === "text" ? htmlToText(rawText) : await htmlToMarkdown(rawText) : rawText;
16329
+ return {
16330
+ url: inputUrl,
16331
+ finalUrl,
16332
+ title,
16333
+ contentType,
16334
+ ...paginateContent(text, options.startIndex, options.maxLength)
16335
+ };
16336
+ } catch (err) {
16337
+ return {
16338
+ url: inputUrl,
16339
+ finalUrl: inputUrl,
16340
+ contentType: "",
16341
+ content: "",
16342
+ contentLength: 0,
16343
+ truncated: false,
16344
+ startIndex: options.startIndex,
16345
+ error: `Fetch failed: ${err instanceof Error ? err.message : String(err)}`
16346
+ };
16347
+ } finally {
16348
+ clearTimeout(timeout);
16349
+ }
16350
+ }
15753
16351
  function createShellTools(_config, hooks) {
15754
16352
  return [
15755
16353
  {
@@ -16016,58 +16614,86 @@ function createShellTools(_config, hooks) {
16016
16614
  // ─── Web Fetch tool (headless URL content extraction) ───
16017
16615
  {
16018
16616
  name: "web_fetch",
16019
- description: "Fetch content from a URL and return it as text. Use this for quick content retrieval without opening a browser. Supports HTML pages (converted to markdown), JSON APIs, and plain text.",
16617
+ description: "Fetch raw content from a URL. Use for APIs, JSON, plain text, and small pages. For articles/docs/pages that need cleanup, prefer web_extract.",
16020
16618
  parameters: {
16021
16619
  type: "object",
16022
16620
  properties: {
16023
16621
  url: { type: "string", description: "The URL to fetch" },
16024
16622
  max_length: { type: "number", description: "Maximum characters to return (default: 5000)" },
16025
- start_index: { type: "number", description: "Start index for content pagination (default: 0). Use to continue reading truncated content." }
16623
+ start_index: { type: "number", description: "Start index for content pagination (default: 0). Use to continue reading truncated content." },
16624
+ allow_private: { type: "boolean", description: "Allow private, loopback, or internal network URLs. Default false." }
16026
16625
  },
16027
16626
  required: ["url"]
16028
16627
  },
16029
16628
  execute: /* @__PURE__ */ __name(async (input) => {
16030
16629
  const url = input.url;
16031
- const maxLength = input.max_length || 5e3;
16032
- const startIndex = input.start_index || 0;
16033
- try {
16034
- const controller = new AbortController();
16035
- const timeout = setTimeout(() => controller.abort(), 15e3);
16036
- const response = await fetch(url, {
16037
- signal: controller.signal,
16038
- headers: { "User-Agent": "OpenJaw-Agent/1.0" }
16039
- });
16040
- clearTimeout(timeout);
16041
- if (!response.ok) {
16042
- return { error: `HTTP ${response.status}: ${response.statusText}`, url };
16043
- }
16044
- const contentType = response.headers.get("content-type") || "";
16045
- let text = await response.text();
16046
- if (contentType.includes("html")) {
16047
- try {
16048
- const TurndownModule = await import("turndown");
16049
- const TurndownService = TurndownModule.default || TurndownModule;
16050
- const turndown = new TurndownService({
16051
- headingStyle: "atx",
16052
- codeBlockStyle: "fenced"
16053
- });
16054
- const cleaned = text.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, "").replace(/<style[^>]*>[\s\S]*?<\/style>/gi, "").replace(/<nav[^>]*>[\s\S]*?<\/nav>/gi, "").replace(/<footer[^>]*>[\s\S]*?<\/footer>/gi, "");
16055
- text = turndown.turndown(cleaned);
16056
- } catch {
16057
- text = text.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, "").replace(/<style[^>]*>[\s\S]*?<\/style>/gi, "").replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
16058
- }
16059
- }
16060
- if (startIndex > 0) {
16061
- text = text.slice(startIndex);
16062
- }
16063
- if (text.length > maxLength) {
16064
- text = text.slice(0, maxLength);
16065
- return { url, contentType, length: text.length, content: text, truncated: true, nextIndex: startIndex + maxLength, hint: `Content truncated. Use start_index: ${startIndex + maxLength} to continue reading.` };
16630
+ const maxLength = clampNumber(input.max_length, 5e3, 1, 1e5);
16631
+ const startIndex = clampNumber(input.start_index, 0, 0, Number.MAX_SAFE_INTEGER);
16632
+ const allowPrivate = input.allow_private === true;
16633
+ const extracted = await extractUrlContent(url, { maxLength, startIndex, allowPrivate, timeoutMs: 15e3, format: "markdown" });
16634
+ if (extracted.error)
16635
+ return { error: extracted.error, url: extracted.url, finalUrl: extracted.finalUrl };
16636
+ return {
16637
+ url: extracted.url,
16638
+ finalUrl: extracted.finalUrl,
16639
+ contentType: extracted.contentType,
16640
+ title: extracted.title,
16641
+ length: extracted.content.length,
16642
+ contentLength: extracted.contentLength,
16643
+ content: extracted.content,
16644
+ truncated: extracted.truncated,
16645
+ ...extracted.nextStartIndex !== void 0 && {
16646
+ nextIndex: extracted.nextStartIndex,
16647
+ nextStartIndex: extracted.nextStartIndex,
16648
+ hint: `Content truncated. Use start_index: ${extracted.nextStartIndex} to continue reading.`
16066
16649
  }
16067
- return { url, contentType, length: text.length, content: text };
16068
- } catch (err) {
16069
- return { error: `Fetch failed: ${err instanceof Error ? err.message : String(err)}`, url };
16070
- }
16650
+ };
16651
+ }, "execute")
16652
+ },
16653
+ // ─── Web Extract tool (selected-source reading) ───
16654
+ {
16655
+ name: "web_extract",
16656
+ description: "Extract readable content from selected web page URLs. Use after web_search when you need to read source pages, articles, docs, or papers. Returns markdown/text with metadata and pagination. For raw APIs or plain JSON, web_fetch is lighter.",
16657
+ parameters: {
16658
+ type: "object",
16659
+ properties: {
16660
+ urls: { type: "array", items: { type: "string" }, description: "List of URLs to extract content from (max 5)" },
16661
+ format: { type: "string", enum: ["markdown", "text"], description: "Output format. HTML is converted to markdown by default.", default: "markdown" },
16662
+ max_length: { type: "number", description: "Maximum characters per URL to return (default: 8000, max: 100000)" },
16663
+ start_index: { type: "number", description: "Start index for content pagination (default: 0). Applies to each URL." },
16664
+ allow_private: { type: "boolean", description: "Allow private, loopback, or internal network URLs. Default false." },
16665
+ use_llm_processing: { type: "boolean", description: "Reserved for future LLM summarization. Currently deterministic extraction only." }
16666
+ },
16667
+ required: ["urls"]
16668
+ },
16669
+ execute: /* @__PURE__ */ __name(async (input) => {
16670
+ const rawUrls = Array.isArray(input.urls) ? input.urls.filter((url) => typeof url === "string") : [];
16671
+ const urls = rawUrls.slice(0, 5);
16672
+ if (urls.length === 0)
16673
+ return { error: "web_extract requires at least one URL", results: [] };
16674
+ const maxLength = clampNumber(input.max_length, 8e3, 1, 1e5);
16675
+ const startIndex = clampNumber(input.start_index, 0, 0, Number.MAX_SAFE_INTEGER);
16676
+ const allowPrivate = input.allow_private === true;
16677
+ const format2 = input.format === "text" ? "text" : "markdown";
16678
+ const results = await Promise.all(urls.map((url) => extractUrlContent(url, { maxLength, startIndex, allowPrivate, format: format2 })));
16679
+ return {
16680
+ results: results.map((result) => ({
16681
+ url: result.url,
16682
+ finalUrl: result.finalUrl,
16683
+ title: result.title,
16684
+ contentType: result.contentType,
16685
+ format: format2,
16686
+ content: result.content,
16687
+ contentLength: result.contentLength,
16688
+ truncated: result.truncated,
16689
+ startIndex: result.startIndex,
16690
+ ...result.nextStartIndex !== void 0 && { nextStartIndex: result.nextStartIndex },
16691
+ ...result.error && { error: result.error }
16692
+ })),
16693
+ count: results.length,
16694
+ ...rawUrls.length > urls.length && { warning: `Only the first ${urls.length} URLs were extracted (max 5 per call).` },
16695
+ ...input.use_llm_processing === true && { llmProcessing: "unavailable: deterministic extraction only in this build" }
16696
+ };
16071
16697
  }, "execute")
16072
16698
  },
16073
16699
  // ─── Sleep tool ───
@@ -16162,12 +16788,14 @@ function createShellTools(_config, hooks) {
16162
16788
  signal: controller.signal
16163
16789
  });
16164
16790
  if (native && Array.isArray(native.results) && native.results.length > 0) {
16791
+ const results = normalizeSearchResults(native.results, maxResults);
16165
16792
  return {
16166
16793
  query,
16167
- resultCount: native.results.length,
16168
- results: native.results,
16794
+ resultCount: results.length,
16795
+ results,
16169
16796
  summary: native.summary,
16170
16797
  provider: native.provider,
16798
+ backend: native.provider,
16171
16799
  durationSeconds: native.durationSeconds
16172
16800
  };
16173
16801
  }
@@ -16218,11 +16846,13 @@ function createShellTools(_config, hooks) {
16218
16846
  results = results.filter((r) => !blockedDomains.some((d) => urlMatchesDomain(r.url, d)));
16219
16847
  }
16220
16848
  if (results.length > 0) {
16849
+ const normalizedResults = normalizeSearchResults(results, maxResults);
16221
16850
  return {
16222
16851
  query,
16223
- resultCount: results.length,
16224
- results,
16852
+ resultCount: normalizedResults.length,
16853
+ results: normalizedResults,
16225
16854
  provider: `duckduckgo-${ep.kind}`,
16855
+ backend: `duckduckgo-${ep.kind}`,
16226
16856
  ...nativeError ? { nativeSearchError: nativeError } : {}
16227
16857
  };
16228
16858
  }
@@ -16287,12 +16917,23 @@ var init_shell = __esm({
16287
16917
  "../openjaw-mcp/dist/tools/shell.js"() {
16288
16918
  "use strict";
16289
16919
  init_web_search_types();
16920
+ init_url_safety();
16290
16921
  __name(truncateOutput, "truncateOutput");
16291
16922
  DECODE_ENTITIES = /* @__PURE__ */ __name((s) => s.replace(/&amp;/g, "&").replace(/&nbsp;/g, " ").replace(/&#x27;/g, "'").replace(/&quot;/g, '"').replace(/&lt;/g, "<").replace(/&gt;/g, ">"), "DECODE_ENTITIES");
16292
16923
  __name(parseLiteResults, "parseLiteResults");
16293
16924
  __name(parseHtmlResults, "parseHtmlResults");
16294
16925
  __name(isAnomalyPage, "isAnomalyPage");
16295
16926
  __name(urlMatchesDomain, "urlMatchesDomain");
16927
+ __name(normalizeSearchResults, "normalizeSearchResults");
16928
+ __name(clampNumber, "clampNumber");
16929
+ __name(extractHtmlTitle, "extractHtmlTitle");
16930
+ __name(cleanHtmlForMarkdown, "cleanHtmlForMarkdown");
16931
+ __name(htmlToText, "htmlToText");
16932
+ __name(isPdfLike, "isPdfLike");
16933
+ __name(htmlToMarkdown, "htmlToMarkdown");
16934
+ __name(paginateContent, "paginateContent");
16935
+ __name(fetchWithValidatedRedirects, "fetchWithValidatedRedirects");
16936
+ __name(extractUrlContent, "extractUrlContent");
16296
16937
  __name(createShellTools, "createShellTools");
16297
16938
  }
16298
16939
  });
@@ -19667,6 +20308,7 @@ var init_categories = __esm({
19667
20308
  ["clipboard_", "system"],
19668
20309
  ["web_fetch", "system"],
19669
20310
  ["web_search", "system"],
20311
+ ["web_extract", "system"],
19670
20312
  ["notify", "system"],
19671
20313
  ["sleep", "system"],
19672
20314
  ["ask_user", "system"],
@@ -20248,6 +20890,8 @@ function parseSkillFile(content, filename) {
20248
20890
  whenToUse: meta.whenToUse || meta.when_to_use,
20249
20891
  tools: Array.isArray(meta.tools) ? meta.tools : meta.tools ? parseYamlArray(meta.tools) : void 0,
20250
20892
  model: meta.model,
20893
+ execution: meta.execution === "fork" ? "fork" : meta.execution === "inline" ? "inline" : void 0,
20894
+ timeoutMs: typeof meta.timeoutMs === "number" ? meta.timeoutMs : typeof meta.timeout_ms === "number" ? meta.timeout_ms : void 0,
20251
20895
  version: meta.version,
20252
20896
  author: meta.author,
20253
20897
  platforms: Array.isArray(meta.platforms) ? meta.platforms : void 0,
@@ -20527,8 +21171,10 @@ function getSkillsSection() {
20527
21171
  const content = `# Available Skills
20528
21172
 
20529
21173
  Use the \`invoke_skill\` tool to execute any skill listed below.
20530
- When a skill matches the user's request, invoke it instead of doing the task manually.
20531
- Skills run in an isolated context with their own conversation \u2014 they won't pollute your main conversation.
21174
+ Skills are for specialized, multi-step workflows that materially benefit from the skill's process.
21175
+ Do NOT invoke a skill for simple one-off questions, quick factual/current-info lookups, basic summaries, or tasks a visible built-in/MCP tool can answer in 1-2 calls.
21176
+ For news/latest/current events, use \`web_search\` directly. For selected URLs, use \`web_extract\` directly.
21177
+ When a skill truly matches a complex workflow, invoke it. Most skills load inline into the current turn so you can continue with visible tools. Only skills marked for fork execution run as isolated sub-agents and may take longer than ordinary tools.
20532
21178
 
20533
21179
  To create new skills or improve existing ones, use \`invoke_skill("skill-creator")\`.
20534
21180
  New skills must be saved to \`~/.openjaw-agent/skills/\` (user skills directory).
@@ -26869,10 +27515,38 @@ __export(skill_tool_exports, {
26869
27515
  createSkillTool: () => createSkillTool
26870
27516
  });
26871
27517
  import { readFileSync as readFileSync23, writeFileSync as writeFileSync15 } from "node:fs";
27518
+ function resolveSkillTimeoutMs() {
27519
+ const raw = process.env["OPENJAW_SKILL_TIMEOUT_MS"];
27520
+ const parsed = raw ? Number(raw) : NaN;
27521
+ if (Number.isFinite(parsed) && parsed > 0) {
27522
+ return Math.min(Math.max(Math.floor(parsed), 1e4), 6e5);
27523
+ }
27524
+ return DEFAULT_SKILL_TIMEOUT_MS;
27525
+ }
27526
+ function resolveForkTimeoutMs(skill) {
27527
+ const configured = skill.meta.timeoutMs;
27528
+ if (typeof configured === "number" && Number.isFinite(configured) && configured > 0) {
27529
+ return Math.min(Math.max(Math.floor(configured), 1e4), 6e5);
27530
+ }
27531
+ return resolveSkillTimeoutMs();
27532
+ }
27533
+ async function withTimeout(promise, ms) {
27534
+ let timeoutId;
27535
+ try {
27536
+ return await Promise.race([
27537
+ promise,
27538
+ new Promise((resolve5) => {
27539
+ timeoutId = setTimeout(() => resolve5(SKILL_TIMEOUT), ms);
27540
+ })
27541
+ ]);
27542
+ } finally {
27543
+ if (timeoutId) clearTimeout(timeoutId);
27544
+ }
27545
+ }
26872
27546
  function createSkillTool(config, toolRegistry, systemPromptFn) {
26873
27547
  return {
26874
27548
  name: "invoke_skill",
26875
- description: "Execute a skill from the available skills list. Skills provide specialized multi-step workflows. Use this when the user's request matches a listed skill.",
27549
+ description: "Execute a skill from the available skills list. Skills are for specialized multi-step workflows. Do not use for simple one-off lookups, quick current-news/factual questions, or tasks a direct built-in tool can answer in 1-2 calls.",
26876
27550
  parameters: {
26877
27551
  type: "object",
26878
27552
  properties: {
@@ -26883,6 +27557,11 @@ function createSkillTool(config, toolRegistry, systemPromptFn) {
26883
27557
  args: {
26884
27558
  type: "string",
26885
27559
  description: "Optional arguments or context for the skill"
27560
+ },
27561
+ mode: {
27562
+ type: "string",
27563
+ enum: ["inline", "fork"],
27564
+ description: "Execution mode. Default inline loads instructions into this turn; fork runs an isolated sub-agent for skills that explicitly support long-running execution."
26886
27565
  }
26887
27566
  },
26888
27567
  required: ["skill"]
@@ -26890,6 +27569,7 @@ function createSkillTool(config, toolRegistry, systemPromptFn) {
26890
27569
  execute: /* @__PURE__ */ __name(async (input) => {
26891
27570
  const skillName = input.skill.trim().replace(/^\//, "");
26892
27571
  const args = input.args || "";
27572
+ const requestedMode = input.mode === "fork" ? "fork" : input.mode === "inline" ? "inline" : void 0;
26893
27573
  const skill = findSkill(skillName);
26894
27574
  if (!skill) {
26895
27575
  clearSkillRegistry();
@@ -26897,17 +27577,39 @@ function createSkillTool(config, toolRegistry, systemPromptFn) {
26897
27577
  if (!retrySkill) {
26898
27578
  return { success: false, error: `Skill "${skillName}" not found. Check /skills for available skills.` };
26899
27579
  }
26900
- return await executeSkill(retrySkill.name, args, config, toolRegistry, systemPromptFn);
27580
+ return await executeSkill(retrySkill, args, requestedMode, config, toolRegistry, systemPromptFn);
26901
27581
  }
26902
- return await executeSkill(skill.name, args, config, toolRegistry, systemPromptFn);
27582
+ return await executeSkill(skill, args, requestedMode, config, toolRegistry, systemPromptFn);
26903
27583
  }, "execute")
26904
27584
  };
26905
27585
  }
26906
- async function executeSkill(skillName, args, config, toolRegistry, systemPromptFn) {
27586
+ async function executeSkill(skill, args, requestedMode, config, toolRegistry, systemPromptFn) {
27587
+ const skillName = skill.name;
26907
27588
  const body = loadSkillPrompt(skillName);
26908
27589
  if (!body) {
26909
27590
  return { success: false, error: `Could not load skill content for "${skillName}"` };
26910
27591
  }
27592
+ const mode = requestedMode ?? skill.meta.execution ?? "inline";
27593
+ if (mode === "fork") {
27594
+ return await executeForkedSkill(skill, body, args, config, toolRegistry, systemPromptFn);
27595
+ }
27596
+ return loadInlineSkill(skill, body, args);
27597
+ }
27598
+ function loadInlineSkill(skill, body, args) {
27599
+ const skillBlock = args ? `${body}
27600
+
27601
+ # User instructions for this skill
27602
+ ${args}` : body;
27603
+ return {
27604
+ success: true,
27605
+ skill: skill.name,
27606
+ mode: "inline",
27607
+ message: "Skill instructions loaded inline. Continue the task in the current conversation using these instructions and visible tools.",
27608
+ instructions: skillBlock
27609
+ };
27610
+ }
27611
+ async function executeForkedSkill(skill, body, args, config, toolRegistry, systemPromptFn) {
27612
+ const skillName = skill.name;
26911
27613
  const baseSections = await systemPromptFn();
26912
27614
  const staticSections = baseSections.slice(0, 4).filter(Boolean);
26913
27615
  const skillPrompt = [...staticSections, body].join("\n\n");
@@ -26917,7 +27619,34 @@ async function executeSkill(skillName, args, config, toolRegistry, systemPromptF
26917
27619
  let lastAnswer = "";
26918
27620
  let thinking = "";
26919
27621
  const allChunks = [];
26920
- for await (const chunk of forkLoop.run(userMessage, skillPrompt)) {
27622
+ const timeoutMs = resolveForkTimeoutMs(skill);
27623
+ const deadline = Date.now() + timeoutMs;
27624
+ const iterator = forkLoop.run(userMessage, skillPrompt)[Symbol.asyncIterator]();
27625
+ while (true) {
27626
+ const remaining = deadline - Date.now();
27627
+ if (remaining <= 0) {
27628
+ forkLoop.abort();
27629
+ return {
27630
+ success: false,
27631
+ skill: skillName,
27632
+ timeoutMs,
27633
+ error: `Skill "${skillName}" exceeded ${Math.round(timeoutMs / 1e3)}s. Use direct tools for simple requests, or rerun with narrower instructions.`,
27634
+ partial: lastAnswer || thinking || void 0
27635
+ };
27636
+ }
27637
+ const next = await withTimeout(iterator.next(), remaining);
27638
+ if (next === SKILL_TIMEOUT) {
27639
+ forkLoop.abort();
27640
+ return {
27641
+ success: false,
27642
+ skill: skillName,
27643
+ timeoutMs,
27644
+ error: `Skill "${skillName}" exceeded ${Math.round(timeoutMs / 1e3)}s. Use direct tools for simple requests, or rerun with narrower instructions.`,
27645
+ partial: lastAnswer || thinking || void 0
27646
+ };
27647
+ }
27648
+ if (next.done) break;
27649
+ const chunk = next.value;
26921
27650
  if (chunk.type === "answer" && chunk.content) lastAnswer = chunk.content;
26922
27651
  if (chunk.type === "thinking" && chunk.content) thinking += chunk.content;
26923
27652
  allChunks.push({ type: chunk.type, content: chunk.content });
@@ -26925,14 +27654,14 @@ async function executeSkill(skillName, args, config, toolRegistry, systemPromptF
26925
27654
  const result = lastAnswer || thinking || "Skill completed (no output)";
26926
27655
  const compressed = result.length > 2e3 ? result.slice(0, 2e3) + `
26927
27656
  ...(${result.length} chars total, truncated)` : result;
26928
- const skill = findSkill(skillName);
26929
- if (skill?.filePath && skill.source === "user") {
27657
+ const currentSkill = findSkill(skillName);
27658
+ if (currentSkill?.filePath && currentSkill.source === "user") {
26930
27659
  const lessons = extractLessons(allChunks);
26931
27660
  if (lessons) {
26932
- appendLessonsLearned(skill.filePath, lessons);
27661
+ appendLessonsLearned(currentSkill.filePath, lessons);
26933
27662
  }
26934
27663
  }
26935
- return { success: true, skill: skillName, result: compressed };
27664
+ return { success: true, skill: skillName, mode: "fork", result: compressed };
26936
27665
  } catch (err) {
26937
27666
  return { success: false, skill: skillName, error: err instanceof Error ? err.message : String(err) };
26938
27667
  }
@@ -26973,13 +27702,21 @@ function appendLessonsLearned(skillPath, lessons) {
26973
27702
  } catch {
26974
27703
  }
26975
27704
  }
27705
+ var DEFAULT_SKILL_TIMEOUT_MS, SKILL_TIMEOUT;
26976
27706
  var init_skill_tool = __esm({
26977
27707
  "src/tools/skill-tool.ts"() {
26978
27708
  "use strict";
26979
27709
  init_agent_loop();
26980
27710
  init_registry2();
27711
+ DEFAULT_SKILL_TIMEOUT_MS = 12e4;
27712
+ SKILL_TIMEOUT = /* @__PURE__ */ Symbol("skill-timeout");
27713
+ __name(resolveSkillTimeoutMs, "resolveSkillTimeoutMs");
27714
+ __name(resolveForkTimeoutMs, "resolveForkTimeoutMs");
27715
+ __name(withTimeout, "withTimeout");
26981
27716
  __name(createSkillTool, "createSkillTool");
26982
27717
  __name(executeSkill, "executeSkill");
27718
+ __name(loadInlineSkill, "loadInlineSkill");
27719
+ __name(executeForkedSkill, "executeForkedSkill");
26983
27720
  __name(extractLessons, "extractLessons");
26984
27721
  __name(appendLessonsLearned, "appendLessonsLearned");
26985
27722
  }
@@ -27832,13 +28569,13 @@ var init_feishu = __esm({
27832
28569
  if (stat2.isFile() && stat2.size < 30 * 1024 * 1024) {
27833
28570
  const fileType = this.getFeishuFileType(fileName);
27834
28571
  this.emit({ type: "system", content: `\u{1F4E4} Uploading to Feishu: ${fileName} (${(stat2.size / 1024).toFixed(0)}KB, type=${fileType})` });
27835
- const withTimeout = /* @__PURE__ */ __name((promise, ms, label) => Promise.race([
28572
+ const withTimeout2 = /* @__PURE__ */ __name((promise, ms, label) => Promise.race([
27836
28573
  promise,
27837
28574
  new Promise((_, reject) => setTimeout(() => reject(new Error(`${label} timed out after ${ms / 1e3}s`)), ms))
27838
28575
  ]), "withTimeout");
27839
28576
  const { createReadStream: crs } = await import("node:fs");
27840
28577
  const t0 = Date.now();
27841
- const uploadResp = await withTimeout(
28578
+ const uploadResp = await withTimeout2(
27842
28579
  this.client.im.file.create({
27843
28580
  data: {
27844
28581
  file_type: fileType,
@@ -27856,7 +28593,7 @@ var init_feishu = __esm({
27856
28593
  this.emit({ type: "system", content: `\u{1F4E4} Upload done in ${(uploadMs / 1e3).toFixed(1)}s: code=${respCode}, file_key=${fileKey ? "\u2713" : "\u2717"}` });
27857
28594
  if (fileKey) {
27858
28595
  const t1 = Date.now();
27859
- const sendResp = await withTimeout(
28596
+ const sendResp = await withTimeout2(
27860
28597
  this.client.im.message.create({
27861
28598
  params: { receive_id_type: "chat_id" },
27862
28599
  data: {
@@ -48666,26 +49403,14 @@ ${helpMessage}` : field.label;
48666
49403
  bus.log("info", `session.steer ${String(params.text ?? "").slice(0, 200)}`);
48667
49404
  return { status: "queued", text: String(params.text ?? "") };
48668
49405
  });
48669
- let pendingImageSeq = 0;
48670
49406
  let pendingImage = null;
48671
- const nextImageAttachmentId = /* @__PURE__ */ __name(() => `img-${Date.now().toString(36)}-${++pendingImageSeq}`, "nextImageAttachmentId");
48672
49407
  bus.registerRpc("prompt.submit", async (params) => {
48673
49408
  const text = String(params.text ?? "");
48674
49409
  if (!text) return { ok: false };
48675
49410
  const systemPromptArr = await systemPromptFn();
48676
49411
  const systemPrompt = systemPromptArr.join("\n\n");
48677
- const requestedImageId = String(params.image_attachment_id ?? "");
48678
- const imageForSubmit = pendingImage && (!requestedImageId || pendingImage.attachmentId === requestedImageId) ? pendingImage : null;
48679
- if (imageForSubmit) {
48680
- const modelInfo = agentLoop.getActiveModelMetadata() ?? await agentLoop.listModels().then(() => agentLoop.getActiveModelMetadata()).catch(() => void 0);
48681
- if (modelInfo?.supportsVision === false) {
48682
- throw new Error(`model ${agentLoop.model} does not support image input; switch to a vision-capable model before submitting an image`);
48683
- }
48684
- }
48685
- const imageData = imageForSubmit ? { base64: imageForSubmit.base64, mimeType: imageForSubmit.mimeType } : void 0;
48686
- if (imageForSubmit) {
48687
- pendingImage = null;
48688
- }
49412
+ const imageData = pendingImage ? { base64: pendingImage.base64, mimeType: pendingImage.mimeType } : void 0;
49413
+ pendingImage = null;
48689
49414
  currentRun = { abort: /* @__PURE__ */ __name(() => agentLoop.abort(), "abort") };
48690
49415
  void streamAgentRun({ agentLoop, bus, systemPrompt, text, imageData }).finally(() => {
48691
49416
  currentRun = null;
@@ -48761,32 +49486,6 @@ ${helpMessage}` : field.label;
48761
49486
  });
48762
49487
  });
48763
49488
  bus.registerRpc("clipboard.paste", async () => {
48764
- try {
48765
- if (clipboardHasImage()) {
48766
- const img = readClipboardImage();
48767
- if (img) {
48768
- const attachmentId = nextImageAttachmentId();
48769
- pendingImage = {
48770
- attachmentId,
48771
- base64: img.base64,
48772
- mimeType: img.mimeType,
48773
- name: "clipboard.png"
48774
- };
48775
- const byteLength = Math.ceil(img.base64.length * 3 / 4);
48776
- const tokenEstimate = Math.max(1, Math.ceil(byteLength / 750));
48777
- return {
48778
- attached: true,
48779
- attachment_id: attachmentId,
48780
- count: 1,
48781
- height: img.height,
48782
- name: "clipboard.png",
48783
- token_estimate: tokenEstimate,
48784
- width: img.width
48785
- };
48786
- }
48787
- }
48788
- } catch {
48789
- }
48790
49489
  try {
48791
49490
  const text = await readClipboardText();
48792
49491
  return { attached: false, message: text ?? "" };
@@ -49411,16 +50110,13 @@ ${helpMessage}` : field.label;
49411
50110
  }
49412
50111
  const ext = extname4(path3).toLowerCase().replace(/^\./, "");
49413
50112
  const mimeType = ext === "png" ? "image/png" : ext === "jpg" || ext === "jpeg" ? "image/jpeg" : ext === "gif" ? "image/gif" : ext === "webp" ? "image/webp" : "image/png";
49414
- const attachmentId = nextImageAttachmentId();
49415
50113
  pendingImage = {
49416
- attachmentId,
49417
50114
  base64: buffer.toString("base64"),
49418
50115
  mimeType,
49419
50116
  name: basename3(path3)
49420
50117
  };
49421
50118
  const tokenEstimate = Math.max(1, Math.ceil(buffer.byteLength / 750));
49422
50119
  return {
49423
- attachment_id: attachmentId,
49424
50120
  height: 0,
49425
50121
  name: basename3(path3),
49426
50122
  remainder,
@@ -49428,13 +50124,6 @@ ${helpMessage}` : field.label;
49428
50124
  width: 0
49429
50125
  };
49430
50126
  });
49431
- bus.registerRpc("image.clear", (params) => {
49432
- const attachmentId = String(params.attachment_id ?? "");
49433
- if (!attachmentId || pendingImage?.attachmentId === attachmentId) {
49434
- pendingImage = null;
49435
- }
49436
- return { ok: true };
49437
- });
49438
50127
  bus.registerRpc("process.stop", () => {
49439
50128
  const wasRunning = agentLoop.isRunning;
49440
50129
  agentLoop.abort();
@@ -49634,7 +50323,6 @@ var init_rpcHandlers = __esm({
49634
50323
  init_uiStore();
49635
50324
  init_catalog();
49636
50325
  init_clipboard();
49637
- init_clipboard_image();
49638
50326
  init_models_static();
49639
50327
  init_providers();
49640
50328
  init_types();
@@ -52896,7 +53584,6 @@ function useComposerState({
52896
53584
  }) {
52897
53585
  const [input, setInput] = useState12("");
52898
53586
  const [inputBuf, setInputBuf] = useState12([]);
52899
- const [attachedImage, setAttachedImage] = useState12(null);
52900
53587
  const [pasteSnips, setPasteSnips] = useState12([]);
52901
53588
  const isBlocked = useStore($isBlocked);
52902
53589
  const { querier } = use_stdin_default();
@@ -52914,25 +53601,14 @@ function useComposerState({
52914
53601
  } = useQueue();
52915
53602
  const { historyRef, historyIdx, setHistoryIdx, historyDraftRef, pushHistory } = useInputHistory();
52916
53603
  const { completions, compIdx, setCompIdx, compReplace } = useCompletion(input, isBlocked, gw);
52917
- const clearAttachedImage = useCallback7(() => {
52918
- const attachmentId = attachedImage?.attachment_id;
52919
- setAttachedImage(null);
52920
- if (attachmentId) {
52921
- void gw.request("image.clear", { attachment_id: attachmentId }).catch(() => {
52922
- });
52923
- }
52924
- }, [attachedImage?.attachment_id, gw]);
52925
- const clearIn = useCallback7((options = {}) => {
53604
+ const clearIn = useCallback7(() => {
52926
53605
  setInput("");
52927
53606
  setInputBuf([]);
52928
53607
  setPasteSnips([]);
52929
- if (!options.keepAttachedImage) {
52930
- clearAttachedImage();
52931
- }
52932
53608
  setQueueEdit(null);
52933
53609
  setHistoryIdx(null);
52934
53610
  historyDraftRef.current = "";
52935
- }, [clearAttachedImage, historyDraftRef, setQueueEdit, setHistoryIdx]);
53611
+ }, [historyDraftRef, setQueueEdit, setHistoryIdx]);
52936
53612
  const handleResolvedPaste = useCallback7(
52937
53613
  async ({
52938
53614
  bracketed,
@@ -52955,13 +53631,6 @@ function useComposerState({
52955
53631
  session_id: sid
52956
53632
  });
52957
53633
  if (attached?.name) {
52958
- setAttachedImage({
52959
- attachment_id: attached.attachment_id,
52960
- height: attached.height,
52961
- name: attached.name,
52962
- token_estimate: attached.token_estimate,
52963
- width: attached.width
52964
- });
52965
53634
  onImageAttached?.(attached);
52966
53635
  const remainder = attached.remainder?.trim() ?? "";
52967
53636
  if (!remainder) {
@@ -53014,7 +53683,7 @@ function useComposerState({
53014
53683
  }) => {
53015
53684
  if (hotkey) {
53016
53685
  const preferOsc52 = isRemoteShellSession(process.env);
53017
- const readPreferredText = /* @__PURE__ */ __name(() => preferOsc52 ? readOsc52Clipboard(querier).then(async (osc52Text) => {
53686
+ const readPreferredText = preferOsc52 ? readOsc52Clipboard(querier).then(async (osc52Text) => {
53018
53687
  if (isUsableClipboardText(osc52Text)) {
53019
53688
  return osc52Text;
53020
53689
  }
@@ -53024,12 +53693,8 @@ function useComposerState({
53024
53693
  return clipText;
53025
53694
  }
53026
53695
  return readOsc52Clipboard(querier);
53027
- }), "readPreferredText");
53028
- return Promise.resolve(onClipboardPaste(true)).then(async (imageAttached) => {
53029
- if (imageAttached) {
53030
- return null;
53031
- }
53032
- const preferredText = await readPreferredText();
53696
+ });
53697
+ return readPreferredText.then(async (preferredText) => {
53033
53698
  if (isUsableClipboardText(preferredText)) {
53034
53699
  return handleResolvedPaste({ bracketed: false, cursor, text: preferredText, value });
53035
53700
  }
@@ -53067,7 +53732,6 @@ function useComposerState({
53067
53732
  }, [input, inputBuf, submitRef]);
53068
53733
  const actions = useMemo6(
53069
53734
  () => ({
53070
- clearAttachedImage,
53071
53735
  clearIn,
53072
53736
  dequeue,
53073
53737
  enqueue,
@@ -53078,7 +53742,6 @@ function useComposerState({
53078
53742
  replaceQueue: replaceQ,
53079
53743
  setCompIdx,
53080
53744
  setHistoryIdx,
53081
- setAttachedImage,
53082
53745
  setInput,
53083
53746
  setInputBuf,
53084
53747
  setPasteSnips,
@@ -53086,7 +53749,6 @@ function useComposerState({
53086
53749
  syncQueue
53087
53750
  }),
53088
53751
  [
53089
- clearAttachedImage,
53090
53752
  clearIn,
53091
53753
  dequeue,
53092
53754
  enqueue,
@@ -53113,7 +53775,6 @@ function useComposerState({
53113
53775
  );
53114
53776
  const state = useMemo6(
53115
53777
  () => ({
53116
- attachedImage,
53117
53778
  compIdx,
53118
53779
  compReplace,
53119
53780
  completions,
@@ -53124,7 +53785,7 @@ function useComposerState({
53124
53785
  queueEditIdx,
53125
53786
  queuedDisplay
53126
53787
  }),
53127
- [attachedImage, compIdx, compReplace, completions, historyIdx, input, inputBuf, pasteSnips, queueEditIdx, queuedDisplay]
53788
+ [compIdx, compReplace, completions, historyIdx, input, inputBuf, pasteSnips, queueEditIdx, queuedDisplay]
53128
53789
  );
53129
53790
  return {
53130
53791
  actions,
@@ -53713,9 +54374,6 @@ function useInputHandlers(ctx) {
53713
54374
  if (key.escape && terminal.hasSelection) {
53714
54375
  return clearSelection2();
53715
54376
  }
53716
- if (key.escape && cState.attachedImage) {
53717
- return cActions.clearAttachedImage();
53718
- }
53719
54377
  if (key.escape && live.focusedPane === "transcript") {
53720
54378
  patchUiState({ focusedPane: "composer" });
53721
54379
  return;
@@ -53764,7 +54422,7 @@ function useInputHandlers(ctx) {
53764
54422
  sys: actions.sys
53765
54423
  });
53766
54424
  }
53767
- if (cState.input || cState.inputBuf.length || cState.attachedImage) {
54425
+ if (cState.input || cState.inputBuf.length) {
53768
54426
  return cActions.clearIn();
53769
54427
  }
53770
54428
  return actions.die();
@@ -53954,7 +54612,6 @@ function useSessionLifecycle(opts) {
53954
54612
  setHistoryItems([]);
53955
54613
  setLastUserMsg("");
53956
54614
  setStickyPrompt("");
53957
- composerActions.clearAttachedImage();
53958
54615
  composerActions.setPasteSnips([]);
53959
54616
  evictInkCaches("half");
53960
54617
  }, [composerActions, setHistoryItems, setLastUserMsg, setStickyPrompt, setVoiceProcessing, setVoiceRecording]);
@@ -53967,7 +54624,6 @@ function useSessionLifecycle(opts) {
53967
54624
  setHistoryItems(info ? [introMsg(info)] : []);
53968
54625
  setStickyPrompt("");
53969
54626
  setLastUserMsg("");
53970
- composerActions.clearAttachedImage();
53971
54627
  composerActions.setPasteSnips([]);
53972
54628
  patchTurnState({ activity: [] });
53973
54629
  patchUiState({ info, usage: usageFrom(info) });
@@ -54198,7 +54854,6 @@ function useSubmission(opts) {
54198
54854
  const expand = expandSnips(composerState.pasteSnips);
54199
54855
  const startSubmit = /* @__PURE__ */ __name((displayText, submitText, showUserMessage2 = true) => {
54200
54856
  const sid2 = getUiState().sid;
54201
- const imageAttachment = composerState.attachedImage;
54202
54857
  if (!sid2) {
54203
54858
  return sys("session not ready yet");
54204
54859
  }
@@ -54211,20 +54866,7 @@ function useSubmission(opts) {
54211
54866
  patchUiState({ busy: true, status: "running\u2026" });
54212
54867
  turnController.bufRef = "";
54213
54868
  turnController.interrupted = false;
54214
- const submitParams = { session_id: sid2, text: submitText };
54215
- if (imageAttachment?.attachment_id) {
54216
- submitParams.image_attachment_id = imageAttachment.attachment_id;
54217
- }
54218
- if (imageAttachment) {
54219
- composerActions.setAttachedImage(null);
54220
- }
54221
- gw.request("prompt.submit", submitParams).catch((e) => {
54222
- if (imageAttachment) {
54223
- composerActions.setAttachedImage(imageAttachment);
54224
- sys(`error: ${e.message}`);
54225
- patchUiState({ busy: false, status: "ready" });
54226
- return;
54227
- }
54869
+ gw.request("prompt.submit", { session_id: sid2, text: submitText }).catch((e) => {
54228
54870
  if (isSessionBusyError(e)) {
54229
54871
  composerActions.enqueue(submitText);
54230
54872
  patchUiState({ busy: true, status: "queued for next turn" });
@@ -54250,7 +54892,7 @@ function useSubmission(opts) {
54250
54892
  startSubmit(r.text || text, expand(r.text || text), showUserMessage);
54251
54893
  }).catch(() => startSubmit(text, expand(text), showUserMessage));
54252
54894
  },
54253
- [appendMessage, composerActions, composerState.attachedImage, composerState.pasteSnips, gw, maybeGoodVibes, setLastUserMsg, sys]
54895
+ [appendMessage, composerActions, composerState.pasteSnips, gw, maybeGoodVibes, setLastUserMsg, sys]
54254
54896
  );
54255
54897
  const shellExec = useCallback9(
54256
54898
  (cmd) => {
@@ -54313,16 +54955,6 @@ function useSubmission(opts) {
54313
54955
  }
54314
54956
  sys(note);
54315
54957
  }, "fallback");
54316
- if (composerState.attachedImage) {
54317
- if (live.sid) {
54318
- turnController.interruptTurn({ appendMessage, gw, sid: live.sid, sys });
54319
- }
54320
- if (hasInterpolation(full)) {
54321
- patchUiState({ busy: true });
54322
- return interpolate(full, send);
54323
- }
54324
- return send(full);
54325
- }
54326
54958
  if (mode === "queue") {
54327
54959
  return composerActions.enqueue(full);
54328
54960
  }
@@ -54344,7 +54976,7 @@ function useSubmission(opts) {
54344
54976
  }
54345
54977
  send(full);
54346
54978
  },
54347
- [appendMessage, composerActions, composerRefs, composerState.attachedImage, gw, interpolate, send, sys]
54979
+ [appendMessage, composerActions, composerRefs, gw, interpolate, send, sys]
54348
54980
  );
54349
54981
  const dispatchSubmission = useCallback9(
54350
54982
  (full) => {
@@ -54369,8 +55001,8 @@ function useSubmission(opts) {
54369
55001
  return;
54370
55002
  }
54371
55003
  const editIdx = composerRefs.queueEditRef.current;
55004
+ composerActions.clearIn();
54372
55005
  if (editIdx !== null) {
54373
- composerActions.clearIn();
54374
55006
  composerActions.replaceQueue(editIdx, full);
54375
55007
  const picked = composerRefs.queueRef.current.splice(editIdx, 1)[0];
54376
55008
  composerActions.syncQueue();
@@ -54387,7 +55019,6 @@ function useSubmission(opts) {
54387
55019
  }
54388
55020
  return sendQueued(picked);
54389
55021
  }
54390
- composerActions.clearIn({ keepAttachedImage: !!composerState.attachedImage });
54391
55022
  composerActions.pushHistory(full);
54392
55023
  if (getUiState().busy) {
54393
55024
  return handleBusyInput(full);
@@ -54398,7 +55029,7 @@ function useSubmission(opts) {
54398
55029
  }
54399
55030
  send(full);
54400
55031
  },
54401
- [appendMessage, composerActions, composerRefs, composerState.attachedImage, handleBusyInput, interpolate, send, sendQueued, shellExec, slashRef]
55032
+ [appendMessage, composerActions, composerRefs, handleBusyInput, interpolate, send, sendQueued, shellExec, slashRef]
54402
55033
  );
54403
55034
  const submit = useCallback9(
54404
55035
  (value) => {
@@ -54516,7 +55147,8 @@ function useMainApp(gw) {
54516
55147
  const scrollRef = useRef13(null);
54517
55148
  const onEventRef = useRef13(() => {
54518
55149
  });
54519
- const clipboardPasteRef = useRef13(() => false);
55150
+ const clipboardPasteRef = useRef13(() => {
55151
+ });
54520
55152
  const submitRef = useRef13(() => {
54521
55153
  });
54522
55154
  const terminalHintsShownRef = useRef13(/* @__PURE__ */ new Set());
@@ -54569,7 +55201,9 @@ function useMainApp(gw) {
54569
55201
  const composer = useComposerState({
54570
55202
  gw,
54571
55203
  onClipboardPaste: /* @__PURE__ */ __name((quiet) => clipboardPasteRef.current(quiet), "onClipboardPaste"),
54572
- onImageAttached: /* @__PURE__ */ __name(() => patchUiState({ status: "image attached" }), "onImageAttached"),
55204
+ onImageAttached: /* @__PURE__ */ __name((info) => {
55205
+ sys(attachedImageNotice(info));
55206
+ }, "onImageAttached"),
54573
55207
  submitRef
54574
55208
  });
54575
55209
  const { actions: composerActions, refs: composerRefs, state: composerState } = composer;
@@ -54777,25 +55411,17 @@ function useMainApp(gw) {
54777
55411
  const paste2 = useCallback10(
54778
55412
  (quiet = false) => rpc("clipboard.paste", { session_id: getUiState().sid }).then((r) => {
54779
55413
  if (!r) {
54780
- return false;
55414
+ return;
54781
55415
  }
54782
55416
  if (r.attached) {
54783
- composerActions.setAttachedImage({
54784
- attachment_id: r.attachment_id,
54785
- height: r.height,
54786
- name: r.name ?? `Image #${r.count ?? 1}`,
54787
- token_estimate: r.token_estimate,
54788
- width: r.width
54789
- });
54790
- patchUiState({ status: "image attached" });
54791
- return true;
55417
+ const meta = imageTokenMeta(r);
55418
+ return sys(`\u{1F4CE} Image #${r.count} attached from clipboard${meta ? ` \xB7 ${meta}` : ""}`);
54792
55419
  }
54793
55420
  if (!quiet) {
54794
55421
  sys(r.message || "No image found in clipboard");
54795
55422
  }
54796
- return false;
54797
55423
  }),
54798
- [composerActions, rpc, sys]
55424
+ [rpc, sys]
54799
55425
  );
54800
55426
  clipboardPasteRef.current = paste2;
54801
55427
  const { dispatchSubmission, send, sendQueued, submit } = useSubmission({
@@ -55035,7 +55661,6 @@ function useMainApp(gw) {
55035
55661
  cols,
55036
55662
  compIdx: composerState.compIdx,
55037
55663
  completions: composerState.completions,
55038
- attachedImage: composerState.attachedImage,
55039
55664
  empty,
55040
55665
  handleTextPaste: composerActions.handleTextPaste,
55041
55666
  input: composerState.input,
@@ -55092,6 +55717,7 @@ var init_useMainApp = __esm({
55092
55717
  init_env();
55093
55718
  init_limits();
55094
55719
  init_details();
55720
+ init_messages();
55095
55721
  init_paths();
55096
55722
  init_useGitBranch();
55097
55723
  init_useVirtualHistory();
@@ -59544,7 +60170,7 @@ var init_emoji = __esm({
59544
60170
  });
59545
60171
 
59546
60172
  // src/lib/externalLink.ts
59547
- import { isIP } from "node:net";
60173
+ import { isIP as isIP2 } from "node:net";
59548
60174
  import { useEffect as useEffect21, useMemo as useMemo13, useState as useState25 } from "react";
59549
60175
  function normalizeExternalUrl(value) {
59550
60176
  const trimmed = value.trim();
@@ -59650,13 +60276,13 @@ function isPrivateIpv6(value) {
59650
60276
  }
59651
60277
  return false;
59652
60278
  }
59653
- function normalizeHostname(value) {
60279
+ function normalizeHostname2(value) {
59654
60280
  const withoutBrackets = value.replace(/^\[/, "").replace(/\]$/, "");
59655
60281
  const withoutZoneId = withoutBrackets.split("%", 1)[0];
59656
60282
  return withoutZoneId.replace(/\.$/, "").toLowerCase();
59657
60283
  }
59658
60284
  function isPrivateOrLocalHost(hostname) {
59659
- const normalized = normalizeHostname(hostname);
60285
+ const normalized = normalizeHostname2(hostname);
59660
60286
  if (!normalized) {
59661
60287
  return true;
59662
60288
  }
@@ -59666,7 +60292,7 @@ function isPrivateOrLocalHost(hostname) {
59666
60292
  if (LOCAL_HOST_SUFFIXES.some((suffix) => normalized.endsWith(suffix))) {
59667
60293
  return true;
59668
60294
  }
59669
- const ipVersion = isIP(normalized);
60295
+ const ipVersion = isIP2(normalized);
59670
60296
  if (ipVersion === 4) {
59671
60297
  return isPrivateIpv4(normalized);
59672
60298
  }
@@ -59850,7 +60476,7 @@ var init_externalLink = __esm({
59850
60476
  __name(parseIpv4Octets, "parseIpv4Octets");
59851
60477
  __name(isPrivateIpv4, "isPrivateIpv4");
59852
60478
  __name(isPrivateIpv6, "isPrivateIpv6");
59853
- __name(normalizeHostname, "normalizeHostname");
60479
+ __name(normalizeHostname2, "normalizeHostname");
59854
60480
  __name(isPrivateOrLocalHost, "isPrivateOrLocalHost");
59855
60481
  __name(isTitleFetchable, "isTitleFetchable");
59856
60482
  __name(decodeHtmlEntities, "decodeHtmlEntities");
@@ -62396,7 +63022,6 @@ var init_appLayout = __esm({
62396
63022
  init_env();
62397
63023
  init_limits();
62398
63024
  init_placeholders();
62399
- init_messages();
62400
63025
  init_inputMetrics();
62401
63026
  init_perfPane();
62402
63027
  init_agentsOverlay();
@@ -62516,7 +63141,6 @@ var init_appLayout = __esm({
62516
63141
  const inputColumns = stableComposerColumns(composer.cols, promptWidth);
62517
63142
  const inputHeight = inputVisualHeight(composer.input, inputColumns);
62518
63143
  const inputMouseRef = useRef19(null);
62519
- const attachedImageMeta = composer.attachedImage ? imageTokenMeta(composer.attachedImage) : "";
62520
63144
  const captureInputDrag = /* @__PURE__ */ __name((e) => {
62521
63145
  if (e.button !== 0) {
62522
63146
  return;
@@ -62587,12 +63211,6 @@ var init_appLayout = __esm({
62587
63211
  ),
62588
63212
  composer.input === "?" && !composer.inputBuf.length && /* @__PURE__ */ jsx41(HelpHint, { t: ui.theme }),
62589
63213
  !isBlocked && /* @__PURE__ */ jsxs28(Fragment11, { children: [
62590
- composer.attachedImage && /* @__PURE__ */ jsx41(Box_default, { width: Math.max(1, composer.cols - 2), children: /* @__PURE__ */ jsxs28(Text9, { color: ui.theme.color.systemNote, wrap: "truncate-end", children: [
62591
- "\u{1F4CE} ",
62592
- composer.attachedImage.name,
62593
- attachedImageMeta ? ` \xB7 ${attachedImageMeta}` : "",
62594
- /* @__PURE__ */ jsx41(Text9, { color: ui.theme.color.muted, children: " \xB7 Esc to remove" })
62595
- ] }) }),
62596
63214
  composer.inputBuf.map((line, i) => /* @__PURE__ */ jsxs28(Box_default, { children: [
62597
63215
  /* @__PURE__ */ jsx41(Box_default, { width: promptWidth, children: i === 0 ? /* @__PURE__ */ jsx41(PromptPrefix, { color: ui.theme.color.muted, promptText, width: promptWidth }) : /* @__PURE__ */ jsx41(Text9, { color: ui.theme.color.muted, children: promptBlank }) }),
62598
63216
  /* @__PURE__ */ jsx41(Text9, { color: ui.theme.color.composeText, children: line || " " })