From 3da9630ce518b7b44bb97b849d5efd35bb1f5d6f Mon Sep 17 00:00:00 2001 From: Aarav Sharma Date: Fri, 31 Jul 2026 20:37:28 -0600 Subject: [PATCH 1/3] feat(world): add browser automation runtime --- .gitignore | 1 + bun.lock | 19 +- package.json | 3 +- packages/kilo-world/package.json | 39 ++ packages/kilo-world/script/build-daemon.ts | 116 ++++ packages/kilo-world/script/daemon.ts | 105 ++++ packages/kilo-world/src/client.ts | 6 + .../kilo-world/src/commands/browser/click.ts | 25 + .../kilo-world/src/commands/browser/close.ts | 9 + .../src/commands/browser/cookies.ts | 79 +++ .../kilo-world/src/commands/browser/drag.ts | 42 ++ .../src/commands/browser/evaluate.ts | 27 + .../kilo-world/src/commands/browser/fill.ts | 22 + .../kilo-world/src/commands/browser/hover.ts | 21 + .../src/commands/browser/navigate.ts | 27 + .../src/commands/browser/press-key.ts | 24 + .../src/commands/browser/screenshot.ts | 47 ++ .../kilo-world/src/commands/browser/scroll.ts | 23 + .../src/commands/browser/snapshot.ts | 11 + .../kilo-world/src/commands/browser/status.ts | 26 + .../kilo-world/src/commands/browser/tabs.ts | 69 +++ .../kilo-world/src/commands/browser/type.ts | 27 + .../src/commands/browser/wait-for.ts | 32 ++ packages/kilo-world/src/config.ts | 58 ++ .../kilo-world/src/core/browser/chrome.ts | 50 ++ .../kilo-world/src/core/browser/detect.ts | 22 + .../kilo-world/src/core/browser/launch.ts | 48 ++ packages/kilo-world/src/core/browser/refs.ts | 243 ++++++++ .../kilo-world/src/core/browser/runner.ts | 213 +++++++ packages/kilo-world/src/daemon/build.ts | 108 ++++ packages/kilo-world/src/daemon/client.ts | 540 ++++++++++++++++++ packages/kilo-world/src/daemon/dispatch.ts | 344 +++++++++++ packages/kilo-world/src/daemon/entry.ts | 7 + packages/kilo-world/src/daemon/protocol.ts | 104 ++++ packages/kilo-world/src/daemon/runtime.ts | 22 + packages/kilo-world/src/daemon/server.ts | 493 ++++++++++++++++ packages/kilo-world/src/index.ts | 64 +++ packages/kilo-world/src/path.ts | 18 + packages/kilo-world/src/script-parser.ts | 97 ++++ packages/kilo-world/src/types.ts | 122 ++++ packages/kilo-world/test/build.test.ts | 51 ++ packages/kilo-world/test/daemon.test.ts | 307 ++++++++++ packages/kilo-world/test/launch.test.ts | 64 +++ packages/kilo-world/test/parse-script.test.ts | 146 +++++ packages/kilo-world/test/runner-node.ts | 37 ++ packages/kilo-world/tsconfig.json | 11 + 46 files changed, 3967 insertions(+), 2 deletions(-) create mode 100644 packages/kilo-world/package.json create mode 100644 packages/kilo-world/script/build-daemon.ts create mode 100644 packages/kilo-world/script/daemon.ts create mode 100644 packages/kilo-world/src/client.ts create mode 100644 packages/kilo-world/src/commands/browser/click.ts create mode 100644 packages/kilo-world/src/commands/browser/close.ts create mode 100644 packages/kilo-world/src/commands/browser/cookies.ts create mode 100644 packages/kilo-world/src/commands/browser/drag.ts create mode 100644 packages/kilo-world/src/commands/browser/evaluate.ts create mode 100644 packages/kilo-world/src/commands/browser/fill.ts create mode 100644 packages/kilo-world/src/commands/browser/hover.ts create mode 100644 packages/kilo-world/src/commands/browser/navigate.ts create mode 100644 packages/kilo-world/src/commands/browser/press-key.ts create mode 100644 packages/kilo-world/src/commands/browser/screenshot.ts create mode 100644 packages/kilo-world/src/commands/browser/scroll.ts create mode 100644 packages/kilo-world/src/commands/browser/snapshot.ts create mode 100644 packages/kilo-world/src/commands/browser/status.ts create mode 100644 packages/kilo-world/src/commands/browser/tabs.ts create mode 100644 packages/kilo-world/src/commands/browser/type.ts create mode 100644 packages/kilo-world/src/commands/browser/wait-for.ts create mode 100644 packages/kilo-world/src/config.ts create mode 100644 packages/kilo-world/src/core/browser/chrome.ts create mode 100644 packages/kilo-world/src/core/browser/detect.ts create mode 100644 packages/kilo-world/src/core/browser/launch.ts create mode 100644 packages/kilo-world/src/core/browser/refs.ts create mode 100644 packages/kilo-world/src/core/browser/runner.ts create mode 100644 packages/kilo-world/src/daemon/build.ts create mode 100644 packages/kilo-world/src/daemon/client.ts create mode 100644 packages/kilo-world/src/daemon/dispatch.ts create mode 100644 packages/kilo-world/src/daemon/entry.ts create mode 100644 packages/kilo-world/src/daemon/protocol.ts create mode 100644 packages/kilo-world/src/daemon/runtime.ts create mode 100644 packages/kilo-world/src/daemon/server.ts create mode 100644 packages/kilo-world/src/index.ts create mode 100644 packages/kilo-world/src/path.ts create mode 100644 packages/kilo-world/src/script-parser.ts create mode 100644 packages/kilo-world/src/types.ts create mode 100644 packages/kilo-world/test/build.test.ts create mode 100644 packages/kilo-world/test/daemon.test.ts create mode 100644 packages/kilo-world/test/launch.test.ts create mode 100644 packages/kilo-world/test/parse-script.test.ts create mode 100644 packages/kilo-world/test/runner-node.ts create mode 100644 packages/kilo-world/tsconfig.json diff --git a/.gitignore b/.gitignore index ad3b43d63d5..75497263517 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,7 @@ node_modules playground tmp dist +packages/kilo-world/.world-daemon-* ts-dist storybook-static .turbo diff --git a/bun.lock b/bun.lock index e2b68afdf4f..bcb86ee4d73 100644 --- a/bun.lock +++ b/bun.lock @@ -474,6 +474,20 @@ "typescript": "catalog:", }, }, + "packages/kilo-world": { + "name": "@kilocode/world", + "version": "0.1.0", + "dependencies": { + "playwright": "catalog:", + }, + "devDependencies": { + "@tsconfig/bun": "catalog:", + "@types/bun": "catalog:", + "@types/node": "catalog:", + "@typescript/native-preview": "catalog:", + "typescript": "catalog:", + }, + }, "packages/llm": { "name": "@opencode-ai/llm", "version": "7.4.18", @@ -901,7 +915,7 @@ "@opentui/keymap": "0.3.4", "@opentui/solid": "0.3.4", "@pierre/diffs": "1.2.10", - "@playwright/test": "1.59.1", + "@playwright/test": "1.57.0", "@shikijs/stream": "4.2.0", "@solid-primitives/storage": "4.3.3", "@solidjs/meta": "0.29.4", @@ -932,6 +946,7 @@ "marked": "17.0.1", "marked-shiki": "1.2.1", "opentui-spinner": "0.0.7", + "playwright": "1.57.0", "remeda": "2.26.0", "remend": "1.3.0", "semver": "7.7.4", @@ -1554,6 +1569,8 @@ "@kilocode/sdk": ["@kilocode/sdk@workspace:packages/sdk/js"], + "@kilocode/world": ["@kilocode/world@workspace:packages/kilo-world"], + "@kobalte/core": ["@kobalte/core@0.13.11", "", { "dependencies": { "@floating-ui/dom": "^1.5.1", "@internationalized/date": "^3.4.0", "@internationalized/number": "^3.2.1", "@kobalte/utils": "^0.9.1", "@solid-primitives/props": "^3.1.8", "@solid-primitives/resize-observer": "^2.0.26", "solid-presence": "^0.1.8", "solid-prevent-scroll": "^0.1.4" }, "peerDependencies": { "solid-js": "^1.8.15" } }, "sha512-hK7TYpdib/XDb/r/4XDBFaO9O+3ZHz4ZWryV4/3BfES+tSQVgg2IJupDnztKXB0BqbSRy/aWlHKw1SPtNPYCFQ=="], "@kobalte/utils": ["@kobalte/utils@0.9.1", "", { "dependencies": { "@solid-primitives/event-listener": "^2.2.14", "@solid-primitives/keyed": "^1.2.0", "@solid-primitives/map": "^0.4.7", "@solid-primitives/media": "^2.2.4", "@solid-primitives/props": "^3.1.8", "@solid-primitives/refs": "^1.0.5", "@solid-primitives/utils": "^6.2.1" }, "peerDependencies": { "solid-js": "^1.8.8" } }, "sha512-eeU60A3kprIiBDAfv9gUJX1tXGLuZiKMajUfSQURAF2pk4ZoMYiqIzmrMBvzcxP39xnYttgTyQEVLwiTZnrV4w=="], diff --git a/package.json b/package.json index cb7a4a91b07..af5837b8569 100644 --- a/package.json +++ b/package.json @@ -66,7 +66,8 @@ "marked": "17.0.1", "marked-shiki": "1.2.1", "remend": "1.3.0", - "@playwright/test": "1.59.1", + "@playwright/test": "1.57.0", + "playwright": "1.57.0", "semver": "7.7.4", "typescript": "5.8.2", "@typescript/native-preview": "7.0.0-dev.20260316.1", diff --git a/packages/kilo-world/package.json b/packages/kilo-world/package.json new file mode 100644 index 00000000000..6d80a874ef9 --- /dev/null +++ b/packages/kilo-world/package.json @@ -0,0 +1,39 @@ +{ + "$schema": "https://json.schemastore.org/package.json", + "name": "@kilocode/world", + "version": "0.1.0", + "type": "module", + "license": "MIT", + "private": true, + "description": "Browser-automation library backing the kilo world agent tool.", + "main": "./src/index.ts", + "module": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": "./src/index.ts", + "./client": "./src/client.ts", + "./daemon": "./src/daemon/runtime.ts", + "./runner": "./src/core/browser/runner.ts", + "./types": "./src/types.ts" + }, + "files": [ + "src" + ], + "scripts": { + "typecheck": "tsgo --noEmit", + "build": "bun build src/index.ts --target bun --external electron --external chromium-bidi --outdir dist --entry-naming index.js && bun run build:daemon", + "build:daemon": "bun run script/build-daemon.ts", + "test": "bun test --timeout 30000", + "test:ci": "bunx playwright install chromium && mkdir -p .artifacts/unit && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml" + }, + "dependencies": { + "playwright": "catalog:" + }, + "devDependencies": { + "@tsconfig/bun": "catalog:", + "@types/bun": "catalog:", + "@types/node": "catalog:", + "@typescript/native-preview": "catalog:", + "typescript": "catalog:" + } +} diff --git a/packages/kilo-world/script/build-daemon.ts b/packages/kilo-world/script/build-daemon.ts new file mode 100644 index 00000000000..449f7541c8c --- /dev/null +++ b/packages/kilo-world/script/build-daemon.ts @@ -0,0 +1,116 @@ +#!/usr/bin/env bun +import fs from "node:fs/promises" +import path from "node:path" +import { WorldDaemon } from "./daemon" +import { fingerprint, fresh, LOCK, LOCK_TIMEOUT_MS, STAMP } from "../src/daemon/build" + +const dir = path.resolve(import.meta.dirname, "../dist") +const root = path.dirname(dir) +await fs.mkdir(dir, { recursive: true }) +const manifest = path.join(dir, WorldDaemon.manifest) +const lock = path.join(dir, LOCK) +const key = await fingerprint(path.resolve(import.meta.dirname, "..")) +const handle = await acquire(Date.now() + LOCK_TIMEOUT_MS) +const pulse = setInterval(() => { + const now = new Date() + void handle.utimes(now, now).catch((err: unknown) => { + process.stderr.write(`failed to refresh kilo-world daemon build lock: ${String(err)}\n`) + }) +}, 1000) +try { + await clean() + await build() +} finally { + clearInterval(pulse) + await release(handle) +} + +async function build(): Promise { + if (await fresh(dir, key, WorldDaemon.filename, WorldDaemon.manifest)) return + const stage = await fs.mkdtemp(path.join(root, ".world-daemon-")) + try { + const file = await WorldDaemon.copy(await WorldDaemon.bundle(), stage) + await Bun.write(path.join(stage, STAMP), `${key}\n`) + const files: unknown = JSON.parse(await Bun.file(path.join(stage, WorldDaemon.manifest)).text()) + if (!Array.isArray(files) || !files.every((item) => typeof item === "string")) { + throw new Error("kilo-world daemon build wrote an invalid manifest") + } + const names = [...new Set([...files, STAMP])] + await Bun.write(path.join(stage, WorldDaemon.manifest), `${JSON.stringify(names, null, 2)}\n`) + await WorldDaemon.smoke(file, path.join(stage, "smoke")) + + const previous = await listed(manifest) + const chunks = names.filter( + (name) => name !== WorldDaemon.filename && name !== WorldDaemon.manifest && name !== STAMP, + ) + for (const name of [...chunks, WorldDaemon.filename, WorldDaemon.manifest, STAMP]) { + await fs.rename(path.join(stage, name), path.join(dir, name)) + } + await Promise.all( + previous.filter((name) => !names.includes(name)).map((name) => fs.rm(path.join(dir, name), { force: true })), + ) + console.log(`built ${path.join(dir, WorldDaemon.filename)}`) + } finally { + await fs.rm(stage, { recursive: true, force: true }) + } +} + +async function acquire(deadline: number): Promise { + try { + const file = await fs.open(lock, "wx", 0o600) + await file.writeFile(`${process.pid}\n`) + return file + } catch (err) { + if (!(err instanceof Error) || !("code" in err) || err.code !== "EEXIST") throw err + if (!(await alive())) await fs.rm(lock, { force: true }) + if (Date.now() >= deadline) { + throw new Error(`timed out waiting for kilo-world daemon build lock: ${lock}`, { cause: err }) + } + await Bun.sleep(100) + return acquire(deadline) + } +} + +async function alive(): Promise { + const age = await fs + .stat(lock) + .then((value) => Date.now() - value.mtimeMs) + .catch(() => Number.POSITIVE_INFINITY) + if (age >= LOCK_TIMEOUT_MS) return false + const pid = await fs + .readFile(lock, "utf8") + .then((value) => Number(value.trim())) + .catch(() => Number.NaN) + if (!Number.isSafeInteger(pid) || pid <= 0) return age < 5000 + try { + process.kill(pid, 0) + return true + } catch (err) { + return err instanceof Error && "code" in err && err.code === "EPERM" + } +} + +async function release(handle: fs.FileHandle): Promise { + const owner = await handle.stat() + const current = await fs.stat(lock).catch(() => undefined) + await handle.close() + if (!current || owner.dev !== current.dev || owner.ino !== current.ino) return + await fs.rm(lock, { force: true }) +} + +async function listed(file: string): Promise { + const files: unknown = await Bun.file(file) + .json() + .catch(() => []) + if (!Array.isArray(files)) return [] + return files.filter((item): item is string => typeof item === "string").map((item) => path.basename(item)) +} + +async function clean(): Promise { + const files = await fs.readdir(root, { withFileTypes: true }) + await Promise.all( + files + .filter((file) => file.isDirectory() && file.name.startsWith(".world-daemon-")) + .map((file) => fs.rm(path.join(root, file.name), { recursive: true, force: true })), + ) +} diff --git a/packages/kilo-world/script/daemon.ts b/packages/kilo-world/script/daemon.ts new file mode 100644 index 00000000000..65e87ee25ae --- /dev/null +++ b/packages/kilo-world/script/daemon.ts @@ -0,0 +1,105 @@ +import path from "node:path" +import fs from "node:fs/promises" +import os from "node:os" +import { spawn } from "node:child_process" +import { ENTRY, MANIFEST } from "../src/daemon/build" + +type Artifact = Blob & { path?: string; kind?: string } + +export namespace WorldDaemon { + export const filename = ENTRY + export const manifest = MANIFEST + + export type Bundle = { entry: Artifact; files: Artifact[] } + + export async function bundle(): Promise { + const entry = path.resolve(import.meta.dirname, "../src/daemon/entry.ts") + const result = await Bun.build({ + entrypoints: [entry], + target: "node", + format: "cjs", + minify: true, + external: ["chromium-bidi", "electron"], + }) + if (!result.success) { + const details = result.logs.map((item) => String(item)).join("\n") + throw new Error(`Could not bundle kilo-world daemon:\n${details}`) + } + const files = result.outputs as Artifact[] + const output = + files.find((item) => item.kind === "entry-point") ?? + files.find((item) => item.path?.endsWith("/entry.js") || item.path?.endsWith("\\entry.js")) ?? + files[0] + if (!output) throw new Error("kilo-world daemon bundle produced no outputs") + return { entry: output, files } + } + + export async function copy(bundle: Bundle, dir: string): Promise { + await Bun.write(path.join(dir, filename), bundle.entry) + const names = new Set([filename]) + for (const file of bundle.files) { + if (file === bundle.entry || !file.path) continue + const name = path.basename(file.path) + names.add(name) + await Bun.write(path.join(dir, name), file) + } + await Bun.write(path.join(dir, manifest), `${JSON.stringify([...names], null, 2)}\n`) + return path.join(dir, filename) + } + + export async function smoke(file: string, dir?: string): Promise { + const node = Bun.which("node") + if (!node) throw new Error("Node is required to smoke-test the kilo-world daemon") + const root = dir ?? (await fs.mkdtemp(path.join(os.tmpdir(), "kilo-world-build-"))) + await fs.mkdir(root, { recursive: true }) + const session = `smoke-${process.pid}` + const handshake = path.join(root, `daemon-${session}.json`) + const child = spawn(node, [file, `--session=${session}`, "--idle=15000"], { + env: { + ...process.env, + KILO_WORLD_HOME: root, + KILO_WORLD_DAEMON_SILENT: "1", + KILO_WORLD_PARENT_PID: String(process.pid), + }, + stdio: ["ignore", "ignore", "inherit"], + windowsHide: true, + }) + try { + for (const _ of Array.from({ length: 100 })) { + if (await Bun.file(handshake).exists()) break + if (child.exitCode !== null) throw new Error(`kilo-world daemon exited ${child.exitCode} during smoke test`) + await Bun.sleep(50) + } + if (!(await Bun.file(handshake).exists())) throw new Error("kilo-world daemon smoke test timed out") + const data: unknown = JSON.parse(await Bun.file(handshake).text()) + if (!record(data) || typeof data.url !== "string" || typeof data.token !== "string") { + throw new Error("kilo-world daemon wrote an invalid smoke-test handshake") + } + const status = await fetch(`${data.url}/call`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ id: "smoke", verb: "__status__", args: [], auth: data.token }), + }).then((response) => response.json()) + if (!record(status) || !record(status.envelope) || status.envelope.runtime !== "node") { + throw new Error("kilo-world daemon smoke test did not run under Node") + } + await fetch(`${data.url}/call`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ id: "stop", verb: "__shutdown__", args: [], auth: data.token }), + }) + for (const _ of Array.from({ length: 100 })) { + if (child.exitCode !== null) break + await Bun.sleep(50) + } + if (child.exitCode === null) throw new Error("kilo-world daemon did not stop after smoke test") + } finally { + if (child.exitCode === null) child.kill() + await fs.rm(root, { recursive: true, force: true }) + } + } +} + +function record(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} diff --git a/packages/kilo-world/src/client.ts b/packages/kilo-world/src/client.ts new file mode 100644 index 00000000000..8e9a83038da --- /dev/null +++ b/packages/kilo-world/src/client.ts @@ -0,0 +1,6 @@ +export { defaultConfig, ensureHome, getConfig, setConfig } from "./config" +export { DaemonClient } from "./daemon/client" +export { parseScript } from "./script-parser" +export { resolvePath } from "./path" +export type { Action, RunOptions, RunResult, WorldConfig } from "./types" +export * from "./types" diff --git a/packages/kilo-world/src/commands/browser/click.ts b/packages/kilo-world/src/commands/browser/click.ts new file mode 100644 index 00000000000..592905f8205 --- /dev/null +++ b/packages/kilo-world/src/commands/browser/click.ts @@ -0,0 +1,25 @@ +import { Runner } from "../../core/browser/runner" +import { Launch } from "../../core/browser/launch" +import { Refs } from "../../core/browser/refs" + +export namespace Click { + export type Input = { + session?: string + ref?: string + selector?: string + timeoutMs?: number + } + + export async function run(input: Input): Promise<{ ref?: string; selector?: string }> { + const session = input.session ?? "default" + const live = await Runner.attach(session) + const page = Runner.activePage(live) + await Refs.use(page, session, input.ref, input.selector, (target) => + target.click({ timeout: input.timeoutMs ?? Launch.TIMEOUT_MS_DEFAULT }), + ) + return { + ...(input.ref ? { ref: input.ref } : {}), + ...(input.selector ? { selector: input.selector } : {}), + } + } +} diff --git a/packages/kilo-world/src/commands/browser/close.ts b/packages/kilo-world/src/commands/browser/close.ts new file mode 100644 index 00000000000..3ccf339ec2a --- /dev/null +++ b/packages/kilo-world/src/commands/browser/close.ts @@ -0,0 +1,9 @@ +import { Runner } from "../../core/browser/runner" + +export namespace Close { + export async function run(session?: string): Promise<{ closed: boolean }> { + const name = session ?? "default" + const existed = await Runner.close(name) + return { closed: existed } + } +} diff --git a/packages/kilo-world/src/commands/browser/cookies.ts b/packages/kilo-world/src/commands/browser/cookies.ts new file mode 100644 index 00000000000..64425e21acf --- /dev/null +++ b/packages/kilo-world/src/commands/browser/cookies.ts @@ -0,0 +1,79 @@ +import type { BrowserContext, Cookie } from "playwright" +import { Runner } from "../../core/browser/runner" +import type { CookieEntry } from "../../types" + +type AddCookieInput = Parameters[0][number] + +export namespace Cookies { + function matchesDomain(cookie: Cookie, domain: string): boolean { + return cookie.domain === domain || cookie.domain.endsWith(`.${domain}`) + } + + function fromPlaywright(c: Cookie): CookieEntry { + const out: CookieEntry = { name: c.name, value: c.value, domain: c.domain, path: c.path } + if (typeof c.expires === "number") out.expires = c.expires + if (typeof c.httpOnly === "boolean") out.httpOnly = c.httpOnly + if (typeof c.secure === "boolean") out.secure = c.secure + if (c.sameSite === "Strict" || c.sameSite === "Lax" || c.sameSite === "None") { + out.sameSite = c.sameSite + } + return out + } + + export async function get(input: { + session?: string + domain: string + }): Promise<{ cookies: CookieEntry[]; count: number }> { + const name = input.session ?? "default" + const live = await Runner.attach(name) + const all = await live.context.cookies() + const filtered = all.filter((cookie: Cookie) => matchesDomain(cookie, input.domain)) + const entries = filtered.map(fromPlaywright) + return { cookies: entries, count: entries.length } + } + + export async function set(input: { + session?: string + name: string + value: string + domain: string + path?: string + }): Promise<{ cookie: CookieEntry }> { + const name = input.session ?? "default" + const live = await Runner.attach(name) + const cookie: AddCookieInput = { + name: input.name, + value: input.value, + domain: input.domain, + path: input.path ?? "/", + } + await live.context.addCookies([cookie]) + const path = input.path ?? "/" + const cookies = await live.context.cookies() + const stored = + cookies.find((item) => item.name === input.name && item.domain === input.domain && item.path === path) ?? + cookies.find((item) => item.name === input.name && matchesDomain(item, input.domain)) + if (!stored) throw new Error(`cookie ${input.name} was not stored for ${input.domain}`) + return { cookie: fromPlaywright(stored) } + } + + export async function clear(input: { session?: string; domain?: string }): Promise<{ cleared: number }> { + const name = input.session ?? "default" + const live = await Runner.attach(name) + const before = (await live.context.cookies()).length + if (input.domain) { + const matched = (await live.context.cookies()).filter((cookie) => matchesDomain(cookie, input.domain!)) + for (const cookie of matched) { + await live.context.clearCookies({ + name: cookie.name, + domain: cookie.domain, + path: cookie.path, + }) + } + } else { + await live.context.clearCookies() + } + const after = (await live.context.cookies()).length + return { cleared: before - after } + } +} diff --git a/packages/kilo-world/src/commands/browser/drag.ts b/packages/kilo-world/src/commands/browser/drag.ts new file mode 100644 index 00000000000..ac9b5d857e2 --- /dev/null +++ b/packages/kilo-world/src/commands/browser/drag.ts @@ -0,0 +1,42 @@ +import { Runner } from "../../core/browser/runner" +import { Refs } from "../../core/browser/refs" + +export namespace Drag { + export type Input = { + session?: string + from?: string + to?: string + } + + const REF_RE = /^e\d+$/ + + async function point( + page: import("playwright").Page, + session: string, + raw: string, + ): Promise<{ x: number; y: number }> { + const ref = REF_RE.test(raw) ? raw : undefined + const selector = ref ? undefined : raw + return Refs.use(page, session, ref, selector, async (target) => { + const box = await target.boundingBox() + if (!box) throw new Error(`no bounding box for ${raw}`) + return { x: box.x + box.width / 2, y: box.y + box.height / 2 } + }) + } + + export async function run(input: Input): Promise<{ from: { x: number; y: number }; to: { x: number; y: number } }> { + const session = input.session ?? "default" + const live = await Runner.attach(session) + const page = Runner.activePage(live) + const fromRaw = input.from + const toRaw = input.to + if (!fromRaw || !toRaw) throw new Error("--from and --to are required") + const from = await point(page, session, fromRaw) + const to = await point(page, session, toRaw) + await page.mouse.move(from.x, from.y) + await page.mouse.down() + await page.mouse.move(to.x, to.y, { steps: 10 }) + await page.mouse.up() + return { from, to } + } +} diff --git a/packages/kilo-world/src/commands/browser/evaluate.ts b/packages/kilo-world/src/commands/browser/evaluate.ts new file mode 100644 index 00000000000..76e7c8f66ff --- /dev/null +++ b/packages/kilo-world/src/commands/browser/evaluate.ts @@ -0,0 +1,27 @@ +import { Runner } from "../../core/browser/runner" + +export namespace Evaluate { + export type Input = { + session?: string + js: string + } + + function safeStringify(value: unknown): unknown { + if (value === undefined) return null + if (typeof value === "bigint") return value.toString() + try { + return JSON.parse(JSON.stringify(value)) + } catch { + if (typeof value === "string") return value + return "[unserializable value]" + } + } + + export async function run(input: Input): Promise<{ result: unknown }> { + const session = input.session ?? "default" + const live = await Runner.attach(session) + const page = Runner.activePage(live) + const result = await page.evaluate(input.js) + return { result: safeStringify(result) } + } +} diff --git a/packages/kilo-world/src/commands/browser/fill.ts b/packages/kilo-world/src/commands/browser/fill.ts new file mode 100644 index 00000000000..7e52268a0b2 --- /dev/null +++ b/packages/kilo-world/src/commands/browser/fill.ts @@ -0,0 +1,22 @@ +import { Runner } from "../../core/browser/runner" +import { Refs } from "../../core/browser/refs" + +export namespace Fill { + export type Input = { + session?: string + value: string + ref?: string + selector?: string + force?: boolean + } + + export async function run(input: Input): Promise<{ length: number }> { + const session = input.session ?? "default" + const live = await Runner.attach(session) + const page = Runner.activePage(live) + await Refs.use(page, session, input.ref, input.selector, (target) => + target.fill(input.value, input.force ? { force: true } : undefined), + ) + return { length: input.value.length } + } +} diff --git a/packages/kilo-world/src/commands/browser/hover.ts b/packages/kilo-world/src/commands/browser/hover.ts new file mode 100644 index 00000000000..845afdbd60d --- /dev/null +++ b/packages/kilo-world/src/commands/browser/hover.ts @@ -0,0 +1,21 @@ +import { Runner } from "../../core/browser/runner" +import { Refs } from "../../core/browser/refs" + +export namespace Hover { + export type Input = { + session?: string + ref?: string + selector?: string + } + + export async function run(input: Input): Promise<{ ref?: string; selector?: string }> { + const session = input.session ?? "default" + const live = await Runner.attach(session) + const page = Runner.activePage(live) + await Refs.use(page, session, input.ref, input.selector, (target) => target.hover()) + return { + ...(input.ref ? { ref: input.ref } : {}), + ...(input.selector ? { selector: input.selector } : {}), + } + } +} diff --git a/packages/kilo-world/src/commands/browser/navigate.ts b/packages/kilo-world/src/commands/browser/navigate.ts new file mode 100644 index 00000000000..c9af3e7272a --- /dev/null +++ b/packages/kilo-world/src/commands/browser/navigate.ts @@ -0,0 +1,27 @@ +import { Runner } from "../../core/browser/runner" +import { Launch } from "../../core/browser/launch" +import { Refs } from "../../core/browser/refs" + +export namespace Navigate { + export type Input = { + session?: string + url: string + wait?: string + timeoutMs?: number + } + + export async function run(input: Input): Promise<{ url: string; finalUrl: string; status: number | null }> { + const session = input.session ?? "default" + const live = await Runner.attach(session, input.timeoutMs) + const page = Runner.activePage(live) + const timeout = input.timeoutMs ?? Launch.TIMEOUT_MS_DEFAULT + Refs.reset(session) + const response = await page.goto(input.url, { waitUntil: "load", timeout }) + if (input.wait) { + await page.waitForSelector(input.wait, { timeout }) + } + const url = page.url() + Runner.touch(session, url) + return { url: input.url, finalUrl: url, status: response?.status() ?? null } + } +} diff --git a/packages/kilo-world/src/commands/browser/press-key.ts b/packages/kilo-world/src/commands/browser/press-key.ts new file mode 100644 index 00000000000..caf2812bc89 --- /dev/null +++ b/packages/kilo-world/src/commands/browser/press-key.ts @@ -0,0 +1,24 @@ +import { Runner } from "../../core/browser/runner" + +export namespace PressKey { + export type Input = { + session?: string + chord: string + } + + export async function run(input: Input): Promise<{ chord: string; keys: number }> { + const session = input.session ?? "default" + const live = await Runner.attach(session) + const page = Runner.activePage(live) + const plus = input.chord.endsWith("+") + const base = plus ? input.chord.slice(0, -1) : input.chord + const keys = base + .split("+") + .map((part) => part.trim()) + .filter(Boolean) + if (plus) keys.push("+") + if (keys.length === 0) throw new Error("chord cannot be empty") + await page.keyboard.press(keys.map((key) => (key === "+" ? "Shift+Equal" : key)).join("+")) + return { chord: input.chord, keys: keys.length } + } +} diff --git a/packages/kilo-world/src/commands/browser/screenshot.ts b/packages/kilo-world/src/commands/browser/screenshot.ts new file mode 100644 index 00000000000..bc0262c9098 --- /dev/null +++ b/packages/kilo-world/src/commands/browser/screenshot.ts @@ -0,0 +1,47 @@ +import { dirname } from "node:path" +import { mkdirSync, writeFileSync } from "node:fs" +import { Launch } from "../../core/browser/launch" +import { Runner } from "../../core/browser/runner" + +export namespace Screenshot { + export type Format = "png" | "jpeg" + + export type Input = { + session?: string + out: string + full?: boolean + waitMs?: number + type?: Format + quality?: number + } + + export async function run(input: Input): Promise<{ out: string; bytes: number; mime: string }> { + const session = input.session ?? "default" + const live = await Runner.attach(session) + const page = Runner.activePage(live) + mkdirSync(dirname(input.out), { recursive: true }) + const wait = input.waitMs ?? Launch.SCREENSHOT_PAINT_WAIT_MS_DEFAULT + if (wait > 0) await page.waitForTimeout(wait) + const type: Format = input.type ?? "jpeg" + const quality = clampQuality(input.quality) + const buffer = await page.screenshot({ + fullPage: input.full ?? false, + type, + timeout: Launch.SCREENSHOT_TIMEOUT_MS_DEFAULT, + ...(type === "jpeg" ? { quality } : {}), + }) + writeFileSync(input.out, buffer) + return { + out: input.out, + bytes: buffer.byteLength, + mime: `image/${type}`, + } + } +} + +function clampQuality(value: number | undefined): number { + if (typeof value !== "number" || !Number.isFinite(value)) return 80 + if (value < 50) return 50 + if (value > 100) return 100 + return Math.round(value) +} diff --git a/packages/kilo-world/src/commands/browser/scroll.ts b/packages/kilo-world/src/commands/browser/scroll.ts new file mode 100644 index 00000000000..7390da8c667 --- /dev/null +++ b/packages/kilo-world/src/commands/browser/scroll.ts @@ -0,0 +1,23 @@ +import { Runner } from "../../core/browser/runner" +import { Refs } from "../../core/browser/refs" + +export namespace Scroll { + export type Input = { + session?: string + ref?: string + selector?: string + dx: number + dy: number + } + + export async function run(input: Input): Promise<{ dx: number; dy: number }> { + const session = input.session ?? "default" + const live = await Runner.attach(session) + const page = Runner.activePage(live) + if (input.ref || input.selector) { + await Refs.use(page, session, input.ref, input.selector, (target) => target.scrollIntoViewIfNeeded()) + } + await page.mouse.wheel(input.dx, input.dy) + return { dx: input.dx, dy: input.dy } + } +} diff --git a/packages/kilo-world/src/commands/browser/snapshot.ts b/packages/kilo-world/src/commands/browser/snapshot.ts new file mode 100644 index 00000000000..49a168fcb50 --- /dev/null +++ b/packages/kilo-world/src/commands/browser/snapshot.ts @@ -0,0 +1,11 @@ +import { Runner } from "../../core/browser/runner" +import { Refs } from "../../core/browser/refs" + +export namespace Snapshot { + export async function run(session?: string): Promise<{ snapshot: string; refs: import("../../types").RefEntry[] }> { + const name = session ?? "default" + const live = await Runner.attach(name) + const page = Runner.activePage(live) + return Refs.capture(name, page) + } +} diff --git a/packages/kilo-world/src/commands/browser/status.ts b/packages/kilo-world/src/commands/browser/status.ts new file mode 100644 index 00000000000..665071eb0ab --- /dev/null +++ b/packages/kilo-world/src/commands/browser/status.ts @@ -0,0 +1,26 @@ +import { Runner } from "../../core/browser/runner" +import type { BrowserStatus } from "../../types" +import { getConfig } from "../../config" + +export namespace Status { + export async function run(): Promise { + const cfg = getConfig() + const probe = await Runner.probeChromium() + const version = Runner.version() + const display = + process.platform === "win32" || process.platform === "darwin" + ? process.platform + : (process.env["DISPLAY"] ?? process.env["WAYLAND_DISPLAY"] ?? process.env["MIR_SOCKET"]) + return { + sessions: Runner.listSessions(), + capability: { + headless: cfg.browser.headless, + ...(display ? { display } : {}), + chromiumReady: probe.state === "available", + ...(version ? { chromiumVersion: version } : {}), + installation: probe, + }, + chromiumPid: null, + } + } +} diff --git a/packages/kilo-world/src/commands/browser/tabs.ts b/packages/kilo-world/src/commands/browser/tabs.ts new file mode 100644 index 00000000000..bd9a1e744d7 --- /dev/null +++ b/packages/kilo-world/src/commands/browser/tabs.ts @@ -0,0 +1,69 @@ +import { Runner } from "../../core/browser/runner" +import { Refs } from "../../core/browser/refs" +import type { TabInfo } from "../../types" + +export namespace Tabs { + export async function list(session?: string): Promise { + const name = session ?? "default" + const live = await Runner.attach(name) + const pages = live.context.pages() + const out: TabInfo[] = [] + for (let index = 0; index < pages.length; index++) { + const page = pages[index] + const url = page.url() + let title: string | undefined + try { + title = await page.title() + } catch { + title = undefined + } + out.push({ index, url, ...(title ? { title } : {}), active: page === live.active }) + } + return out + } + + export async function open(input: { session?: string; url?: string }): Promise<{ index: number; url: string }> { + const name = input.session ?? "default" + const url = input.url ?? "about:blank" + const live = await Runner.attach(name) + const page = await live.context.newPage() + live.active = page + Refs.reset(name) + await page.goto(url, { waitUntil: "load" }) + Runner.touch(name, page.url()) + return { index: live.context.pages().indexOf(page), url: page.url() } + } + + export async function select(input: { session?: string; index: number }): Promise<{ index: number; url: string }> { + const name = input.session ?? "default" + const live = await Runner.attach(name) + const pages = live.context.pages() + if (input.index < 0 || input.index >= pages.length) { + throw new Error(`tab index out of range: ${input.index}`) + } + const page = pages[input.index] + live.active = page + Refs.reset(name) + Runner.touch(name, page.url()) + return { index: input.index, url: page.url() } + } + + export async function close(input: { + session?: string + index?: number + }): Promise<{ closed: number; remaining: number }> { + const name = input.session ?? "default" + const live = await Runner.attach(name) + const pages = live.context.pages() + const target = input.index ?? pages.indexOf(Runner.activePage(live)) + if (target < 0 || target >= pages.length) throw new Error(`tab index out of range: ${target}`) + const page = pages[target] + await page.close() + const remaining = live.context.pages() + if (page === live.active) + live.active = remaining[Math.min(target, remaining.length - 1)] ?? (await live.context.newPage()) + Refs.reset(name) + Runner.touch(name, live.active.url()) + return { closed: target, remaining: live.context.pages().length } + } +} diff --git a/packages/kilo-world/src/commands/browser/type.ts b/packages/kilo-world/src/commands/browser/type.ts new file mode 100644 index 00000000000..f40a44197b0 --- /dev/null +++ b/packages/kilo-world/src/commands/browser/type.ts @@ -0,0 +1,27 @@ +import { Runner } from "../../core/browser/runner" +import { Refs } from "../../core/browser/refs" + +export namespace Type { + export type Input = { + session?: string + text: string + ref?: string + selector?: string + delay?: number + } + + export async function run(input: Input): Promise<{ typed: number }> { + const session = input.session ?? "default" + const live = await Runner.attach(session) + const page = Runner.activePage(live) + if (input.ref || input.selector) { + await Refs.use(page, session, input.ref, input.selector, async (target) => { + await target.fill("") + await target.type(input.text, input.delay ? { delay: input.delay } : undefined) + }) + } else { + await page.keyboard.type(input.text, input.delay ? { delay: input.delay } : undefined) + } + return { typed: input.text.length } + } +} diff --git a/packages/kilo-world/src/commands/browser/wait-for.ts b/packages/kilo-world/src/commands/browser/wait-for.ts new file mode 100644 index 00000000000..41a89a081a7 --- /dev/null +++ b/packages/kilo-world/src/commands/browser/wait-for.ts @@ -0,0 +1,32 @@ +import { Runner } from "../../core/browser/runner" +import { Launch } from "../../core/browser/launch" + +export namespace WaitFor { + export type Input = { + session?: string + selector?: string + text?: string + url?: string + timeoutMs?: number + } + + export async function run(input: Input): Promise<{ matched: boolean }> { + const session = input.session ?? "default" + const live = await Runner.attach(session) + const page = Runner.activePage(live) + const timeout = input.timeoutMs ?? Launch.TIMEOUT_MS_DEFAULT + if (input.selector) { + await page.waitForSelector(input.selector, { timeout }) + return { matched: true } + } + if (input.text) { + await page.getByText(input.text).first().waitFor({ timeout }) + return { matched: true } + } + if (input.url) { + await page.waitForURL(input.url, { timeout }) + return { matched: true } + } + throw new Error("must provide one of --selector, --text, or --url") + } +} diff --git a/packages/kilo-world/src/config.ts b/packages/kilo-world/src/config.ts new file mode 100644 index 00000000000..0a6b06fd4ef --- /dev/null +++ b/packages/kilo-world/src/config.ts @@ -0,0 +1,58 @@ +import { existsSync, mkdirSync } from "node:fs" +import { homedir } from "node:os" +import { join } from "node:path" +import type { WorldConfig, WorldConfigPatch } from "./types" + +const DEFAULT_HOME = join(process.env["XDG_STATE_HOME"] ?? join(homedir(), ".local", "state"), "kilo-world") + +let cached: WorldConfig | null = null + +export function defaultConfig(): WorldConfig { + const display = process.env["DISPLAY"] + const wayland = process.env["WAYLAND_DISPLAY"] + const desktop = process.platform === "darwin" || process.platform === "win32" + const displayServer = desktop || Boolean(display || wayland) + const headless = !displayServer || process.env["KILO_WORLD_HEADED"] !== "1" + return { + browser: { + headless, + antiDetect: process.env["KILO_WORLD_ANTI_DETECT"] === "1", + timeoutMs: 30_000, + viewport: { width: 1280, height: 720 }, + args: [], + ...(process.env["KILO_WORLD_CHROMIUM"] ? { executablePath: process.env["KILO_WORLD_CHROMIUM"] } : {}), + }, + home: process.env["KILO_WORLD_HOME"] ?? DEFAULT_HOME, + } +} + +export function getConfig(): WorldConfig { + if (!cached) cached = defaultConfig() + return cached +} + +export function setConfig(patch: WorldConfigPatch): WorldConfig { + const next: WorldConfig = { + ...getConfig(), + ...patch, + browser: { ...getConfig().browser, ...patch.browser }, + } + if (!Number.isFinite(next.browser.timeoutMs) || next.browser.timeoutMs <= 0) { + throw new Error("browser timeout must be a positive finite number") + } + if ( + !Number.isFinite(next.browser.viewport.width) || + next.browser.viewport.width <= 0 || + !Number.isFinite(next.browser.viewport.height) || + next.browser.viewport.height <= 0 + ) { + throw new Error("browser viewport dimensions must be positive finite numbers") + } + cached = next + return next +} + +export function ensureHome(home: string): string { + if (!existsSync(home)) mkdirSync(home, { recursive: true, mode: 0o700 }) + return home +} diff --git a/packages/kilo-world/src/core/browser/chrome.ts b/packages/kilo-world/src/core/browser/chrome.ts new file mode 100644 index 00000000000..5a14a9cd60d --- /dev/null +++ b/packages/kilo-world/src/core/browser/chrome.ts @@ -0,0 +1,50 @@ +import { existsSync } from "node:fs" + +const CANDIDATES: Record = { + darwin: [ + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + "/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary", + "/Applications/Chromium.app/Contents/MacOS/Chromium", + `${process.env["HOME"]}/Applications/Google Chrome.app/Contents/MacOS/Google Chrome`, + ], + win32: [ + "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe", + "C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", + `${process.env["LOCALAPPDATA"]}\\Google\\Chrome\\Application\\chrome.exe`, + `${process.env["PROGRAMFILES"]}\\Google\\Chrome\\Application\\chrome.exe`, + `${process.env["PROGRAMFILES(X86)"]}\\Google\\Chrome\\Application\\chrome.exe`, + "C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe", + "C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe", + `${process.env["LOCALAPPDATA"]}\\Microsoft\\Edge\\Application\\msedge.exe`, + `${process.env["LOCALAPPDATA"]}\\BraveSoftware\\Brave-Browser\\Application\\brave.exe`, + ], + linux: [ + "/usr/bin/google-chrome", + "/usr/bin/google-chrome-stable", + "/usr/bin/chromium", + "/usr/bin/chromium-browser", + "/snap/bin/chromium", + "/usr/bin/google-chrome-beta", + "/usr/bin/google-chrome-canary", + "/usr/bin/microsoft-edge", + "/usr/bin/microsoft-edge-stable", + "/usr/bin/brave-browser", + ], + aix: [], + android: [], + cygwin: [], + freebsd: ["/usr/local/bin/chromium", "/usr/bin/chromium", "/usr/local/bin/google-chrome"], + haiku: [], + netbsd: [], + openbsd: [], + sunos: [], +} + +export function findSystemChrome(): string | undefined { + const os = process.platform + const paths = CANDIDATES[os as NodeJS.Platform] ?? [] + for (const path of paths) { + if (path && existsSync(path)) return path + } + return undefined +} diff --git a/packages/kilo-world/src/core/browser/detect.ts b/packages/kilo-world/src/core/browser/detect.ts new file mode 100644 index 00000000000..c3ec9788a2d --- /dev/null +++ b/packages/kilo-world/src/core/browser/detect.ts @@ -0,0 +1,22 @@ +export const ANTI_DETECT = ` +(() => { + Object.defineProperty(navigator, 'webdriver', { get: () => undefined }); + const origQuery = window.navigator.permissions && window.navigator.permissions.query; + if (origQuery) { + window.navigator.permissions.query = (params) => { + if (params && params.name === 'notifications') return Promise.resolve({ state: Notification.permission, onchange: null }); + return origQuery(params); + }; + } + Object.defineProperty(navigator, 'languages', { get: () => ['en-US', 'en'], configurable: true }); + const plugins = [ + { name: 'Chrome PDF Plugin', filename: 'internal-pdf-viewer', description: 'Portable Document Format' }, + { name: 'Chrome PDF Viewer', filename: 'mhnbgjgfghfpdbnlgfkgmcbpfgjjdhhl', description: '' }, + { name: 'Native Client', filename: 'internal-nacl-plugin', description: '' } + ]; + Object.defineProperty(navigator, 'plugins', { get: () => plugins, configurable: true }); + const nativeChrome = window.chrome; + const hasChrome = !!(nativeChrome && (nativeChrome.runtime || nativeChrome.csi)); + Object.defineProperty(window, 'chrome', { get: () => (hasChrome ? nativeChrome : { runtime: {}, csi: () => {}, loadTimes: () => ({}) }), configurable: true }); +})(); +` diff --git a/packages/kilo-world/src/core/browser/launch.ts b/packages/kilo-world/src/core/browser/launch.ts new file mode 100644 index 00000000000..0a328fe00bd --- /dev/null +++ b/packages/kilo-world/src/core/browser/launch.ts @@ -0,0 +1,48 @@ +import type { WorldConfig } from "../../types" +import { findSystemChrome } from "./chrome" + +export namespace Launch { + export type Options = { + headless: boolean + antiDetect: boolean + timeoutMs: number + viewport: { width: number; height: number } + executablePath?: string + args: string[] + } + + export function fromConfig(cfg: WorldConfig): Options { + const exe = cfg.browser.executablePath ?? (cfg.browser.useSystemChrome ? findSystemChrome() : undefined) + const out: Options = { + headless: cfg.browser.headless, + antiDetect: cfg.browser.antiDetect, + timeoutMs: cfg.browser.timeoutMs, + viewport: cfg.browser.viewport, + args: [...cfg.browser.args], + } + if (exe) out.executablePath = exe + return out + } + + export function launchArgs(opts: Options): string[] { + const args = [...opts.args] + args.push(`--window-size=${opts.viewport.width},${opts.viewport.height}`) + return Array.from(new Set(args)) + } + + export function hide(headless: boolean, platform: NodeJS.Platform = process.platform): boolean { + return platform === "win32" && headless + } + + export function channel(opts: Pick): "chromium" | undefined { + return opts.headless && !opts.executablePath ? "chromium" : undefined + } + + export function contextOptions(opts: Options): { viewport: { width: number; height: number } } { + return { viewport: opts.viewport } + } + + export const TIMEOUT_MS_DEFAULT = 30_000 + export const SCREENSHOT_TIMEOUT_MS_DEFAULT = 10_000 + export const SCREENSHOT_PAINT_WAIT_MS_DEFAULT = 750 +} diff --git a/packages/kilo-world/src/core/browser/refs.ts b/packages/kilo-world/src/core/browser/refs.ts new file mode 100644 index 00000000000..f6767bcc190 --- /dev/null +++ b/packages/kilo-world/src/core/browser/refs.ts @@ -0,0 +1,243 @@ +import type { ElementHandle, Page } from "playwright" +import type { RefEntry, Snapshot } from "../../types" + +type WalkedNode = { + ref: string + role: string | null + name: string +} + +type WalkResult = { + nodes: WalkedNode[] +} + +function record(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} + +function walked(value: unknown): value is WalkedNode { + if (!record(value)) return false + return ( + typeof value.ref === "string" && + (typeof value.role === "string" || value.role === null) && + typeof value.name === "string" + ) +} + +function result(value: unknown): value is WalkResult { + return record(value) && Array.isArray(value.nodes) && value.nodes.every(walked) +} + +const WALK_SCRIPT = ` +(() => { + const out = { nodes: [], counter: { value: 1 } }; + const visited = new WeakSet(); + + function clear(root) { + for (const el of Array.from(root.querySelectorAll('[data-kilo-ref]'))) el.removeAttribute('data-kilo-ref'); + for (const el of Array.from(root.querySelectorAll('*'))) { + if (el.shadowRoot) clear(el.shadowRoot); + } + } + + function roleOf(el) { + const r = el.getAttribute('role'); + if (r) return r; + const t = el.tagName.toLowerCase(); + if (t === 'button') return 'button'; + if (t === 'a') return 'link'; + if (t === 'select') return 'combobox'; + if (t === 'textarea') return 'textbox'; + if (t === 'input') { + const type = (el.type || '').toLowerCase(); + if (type === 'checkbox') return 'checkbox'; + if (type === 'radio') return 'radio'; + if (type === 'button' || type === 'submit' || type === 'reset') return 'button'; + return 'textbox'; + } + if (/^h[1-6]$/.test(t)) return 'heading'; + return null; + } + + function nameOf(el) { + // Compute the accessible name the way Playwright does for role=name=. + // Priority: aria-labelledby > aria-label > label[for=id] > placeholder > name > text. + const labelledBy = el.getAttribute('aria-labelledby'); + if (labelledBy) { + const ids = labelledBy.split(/\\s+/); + const parts = ids.map((id) => { const ref = document.getElementById(id); return ref ? (ref.textContent || '').trim() : ''; }).filter(Boolean); + if (parts.length > 0) return parts.join(' ').slice(0, 100); + } + if (el.getAttribute('aria-label')) return el.getAttribute('aria-label'); + if (el.id) { + const lbl = document.querySelector('label[for="' + el.id.replace(/"/g, '\\\\"') + '"]'); + if (lbl) { + const txt = (lbl.textContent || '').replace(/\\*+/g, '').replace(/\\s+/g, ' ').trim(); + if (txt) return txt.slice(0, 100); + } + } + if (el.getAttribute('placeholder')) return el.getAttribute('placeholder'); + if (el.getAttribute('name')) return el.getAttribute('name'); + const inner = (el.innerText || el.textContent || '').trim(); + return inner.slice(0, 100); + } + + function isInteractive(el) { + const tag = el.tagName.toLowerCase(); + if (['input', 'button', 'select', 'textarea', 'a'].includes(tag)) return true; + if (el.getAttribute('role')) return true; + if (el.getAttribute('onclick')) return true; + if (el.getAttribute('tabindex')) return true; + if (el.getAttribute('contenteditable') === 'true') return true; + return false; + } + + function isHidden(el) { + const style = getComputedStyle(el); + return el.hidden || el.getAttribute('aria-hidden') === 'true' || style.display === 'none' || style.visibility === 'hidden' || style.visibility === 'collapse'; + } + + function walk(root) { + const kids = Array.from(root.children); + for (const el of kids) { + if (visited.has(el)) continue; + visited.add(el); + const tag = el.tagName.toLowerCase(); + if (['script', 'style', 'noscript', 'meta', 'link', 'head', 'title'].includes(tag)) continue; + if (isHidden(el)) continue; + if (isInteractive(el) || (el.textContent || '').trim().length > 0) { + const role = roleOf(el); + const name = (nameOf(el) || '').trim(); + if (role || (el.tagName.toLowerCase() === 'a' && name)) { + const ref = 'e' + out.counter.value++; + el.setAttribute('data-kilo-ref', ref); + out.nodes.push({ + ref, + role, + name, + }); + } + } + const sr = el.shadowRoot; + if (sr) { + walk(sr); + } + walk(el); + } + } + + clear(document); + walk(document.body); + return out; +})(); +` + +export namespace Refs { + export function stash(session: string, snapshot: Snapshot): void { + CACHE.set(session, snapshot) + } + + export function lookup(session: string): Snapshot | undefined { + return CACHE.get(session) + } + + export function reset(session: string): void { + CACHE.delete(session) + } + + export function clear(): void { + CACHE.clear() + } + + export function resolve(session: string, ref: string): { selector: string; entry: RefEntry } { + const snap = CACHE.get(session) + if (!snap) throw new Error(`no snapshot cached for session "${session}" — run snapshot first`) + const entry = snap.refs.find((r) => r.ref === ref) + if (!entry) throw new Error(`ref "${ref}" not in current snapshot`) + if (!entry.selector) throw new Error(`ref "${ref}" has no resolvable selector`) + return { selector: entry.selector, entry } + } + + /** + * Resolve a ref or selector to a Playwright ElementHandle. + * Selectors may include the `>>` shadow-piercing operator + * (e.g. `#shadow-host >> #shadow-pin`); refs always use the + * selector captured at snapshot time, which is also a `>>` chain + * when the element lives in a shadow root. + */ + export async function refOrSelector( + page: Page, + session: string, + ref: string | undefined, + selector: string | undefined, + ): Promise { + if (ref) { + const resolved = resolve(session, ref) + return resolveLocator(page, resolved.selector) + } + if (selector) return resolveLocator(page, selector) + throw new Error("must provide either --ref or --selector") + } + + export async function use( + page: Page, + session: string, + ref: string | undefined, + selector: string | undefined, + fn: (target: ElementHandle) => Promise, + ): Promise { + const target = await refOrSelector(page, session, ref, selector) + if (!target) throw new Error(`no element found for ${ref ?? selector}`) + try { + return await fn(target) + } finally { + await target.dispose() + } + } + + export async function capture(session: string, page: Page): Promise { + const value: unknown = await page.evaluate(WALK_SCRIPT) + if (!result(value)) throw new Error("browser snapshot returned an invalid result") + const refs: RefEntry[] = [] + for (const node of value.nodes) { + const ref = node.ref + const entry: RefEntry = { + ref, + role: node.role ?? "", + name: node.name, + depth: 0, + } + const sel = buildSelector(node) + if (sel) entry.selector = sel + refs.push(entry) + } + const snapshot = renderTextTree(refs) + const out: Snapshot = { snapshot, refs } + stash(session, out) + return out + } +} + +function buildSelector(node: WalkedNode): string { + return `[data-kilo-ref="${node.ref}"]` +} + +function renderTextTree(refs: RefEntry[]): string { + return refs.map((r: RefEntry) => `- [ref=${r.ref}] [${r.role}] ${JSON.stringify(r.name)}`).join("\n") +} + +function resolveLocator(page: Page, selector: string): Promise { + // page.$() only supports CSS selectors; role selectors require locator(). + // We always go through locator() to support both. + if (selector.includes(">>")) { + const parts = selector.split(">>").map((s) => s.trim()) + let loc = page.locator(parts[0]).first() + for (let i = 1; i < parts.length; i++) { + loc = loc.locator(parts[i]) + } + return loc.elementHandle() + } + return page.locator(selector).first().elementHandle() +} + +const CACHE = new Map() diff --git a/packages/kilo-world/src/core/browser/runner.ts b/packages/kilo-world/src/core/browser/runner.ts new file mode 100644 index 00000000000..321b53d9486 --- /dev/null +++ b/packages/kilo-world/src/core/browser/runner.ts @@ -0,0 +1,213 @@ +import { existsSync, mkdirSync, rmSync } from "node:fs" +import childProcess, { type ChildProcess, type SpawnOptions, type SpawnSyncOptions } from "node:child_process" +import { join } from "node:path" +import { chromium, type Browser, type BrowserContext, type LaunchOptions, type Page } from "playwright" +import { ensureHome, getConfig, setConfig } from "../../config" +import { Launch } from "./launch" +import { ANTI_DETECT } from "./detect" +import { Refs } from "./refs" +import { findSystemChrome } from "./chrome" +import type { BrowserCapability, SessionInfo, WorldConfigPatch } from "../../types" + +type Live = { + name: string + context: BrowserContext + browser: Browser + active: Page + home: string +} + +let activeBrowser: Browser | null = null +let pending: Promise | null = null +const activeContexts: Map = new Map() +const sessions: Map = new Map() + +function home(): string { + return ensureHome(getConfig().home) +} + +function folder(home: string, name: string): string { + const key = Buffer.from(name).toString("base64url") || "default" + return join(home, "contexts", key) +} + +function track(name: string, url?: string): void { + const now = Date.now() + const existing = sessions.get(name) + sessions.set(name, { + name, + createdAt: existing?.createdAt ?? now, + lastUsedAt: now, + ...(url ? { lastUrl: url } : {}), + }) +} + +function buildLaunchOptions(): LaunchOptions { + const opts = Launch.fromConfig(getConfig()) + const out: LaunchOptions = { + headless: opts.headless, + timeout: opts.timeoutMs, + args: Launch.launchArgs(opts), + } + if (!opts.executablePath && !existsSync(chromium.executablePath())) { + opts.executablePath = findSystemChrome() + } + if (opts.executablePath) { + out.executablePath = opts.executablePath + } else out.channel = Launch.channel(opts) + return out +} + +async function launch(timeout?: number): Promise { + const opts = buildLaunchOptions() + if (timeout !== undefined) opts.timeout = timeout + if (!Launch.hide(opts.headless === true)) return chromium.launch(opts) + + // Playwright's browser process launcher omits windowsHide. Unified headless + // Chromium uses the full browser executable, but creates no visible browser + // window, so hiding its console window is safe. Never apply this to headed + // Chrome because windowsHide would also hide the browser window. + type Spawn = (command: string, args: readonly string[], options: SpawnOptions) => ChildProcess + const api = childProcess as unknown as { spawn: Spawn; spawnSync: typeof childProcess.spawnSync } + const spawn = api.spawn + const sync = api.spawnSync + api.spawn = (command, args, options) => spawn(command, args, { ...options, windowsHide: true }) + api.spawnSync = ((command: string, args?: readonly string[] | SpawnSyncOptions, options?: SpawnSyncOptions) => { + if (Array.isArray(args)) return sync(command, args, { ...options, windowsHide: true }) + const opts = args as SpawnSyncOptions | undefined + return sync(command, { ...opts, windowsHide: true }) + }) as typeof sync + try { + return await chromium.launch(opts) + } finally { + api.spawn = spawn + api.spawnSync = sync + } +} + +export namespace Runner { + export async function configure(cfg: WorldConfigPatch): Promise { + const current = Launch.fromConfig(getConfig()) + const next = Launch.fromConfig(setConfig(cfg)) + if (JSON.stringify(current) === JSON.stringify(next)) return + await shutdown() + } + + export function version(): string | undefined { + return activeBrowser?.isConnected() ? activeBrowser.version() : undefined + } + export function listSessions(): SessionInfo[] { + return Array.from(sessions.values()).sort((a, b) => a.name.localeCompare(b.name)) + } + + export async function ensureBrowser(timeout?: number): Promise { + if (activeBrowser && activeBrowser.isConnected()) return activeBrowser + if (pending) return pending + const task = launch(timeout).then((browser) => { + activeBrowser = browser + activeContexts.clear() + browser.on("disconnected", () => { + if (activeBrowser === browser) { + activeBrowser = null + activeContexts.clear() + Refs.clear() + } + }) + return browser + }) + const current = task.finally(() => { + if (pending === current) pending = null + }) + pending = current + return current + } + + export async function attach(name: string, timeout?: number): Promise { + const existing = activeContexts.get(name) + if (existing && existing.browser.isConnected()) { + const page = existing.active.isClosed() ? existing.context.pages()[0] : existing.active + if (page) { + existing.active = page + track(name, page.url()) + return existing + } + const fresh = await existing.context.newPage().catch(() => undefined) + if (fresh) { + existing.active = fresh + track(name, fresh.url()) + return existing + } + activeContexts.delete(name) + Refs.reset(name) + await existing.context.close().catch(() => undefined) + } + const browser = await ensureBrowser(timeout) + const opts = Launch.fromConfig(getConfig()) + const ctx = await browser.newContext(Launch.contextOptions(opts)) + const page = await (async () => { + if (opts.antiDetect) await ctx.addInitScript(ANTI_DETECT) + return ctx.newPage() + })().catch(async (err) => { + await ctx.close().catch(() => undefined) + throw err + }) + const live: Live = { + name, + context: ctx, + browser, + active: page, + home: home(), + } + activeContexts.set(name, live) + mkdirSync(folder(live.home, name), { recursive: true }) + track(name) + return live + } + + export async function close(name: string): Promise { + const live = activeContexts.get(name) + if (!live) return false + activeContexts.delete(name) + sessions.delete(name) + Refs.reset(name) + await live.context.close() + rmSync(folder(live.home, name), { recursive: true, force: true }) + return true + } + + export async function shutdown(): Promise { + const launch = pending + if (launch) await launch.catch(() => undefined) + await Promise.allSettled(Array.from(activeContexts.values(), (live) => live.context.close())) + activeContexts.clear() + sessions.clear() + Refs.clear() + if (activeBrowser) { + await activeBrowser.close() + activeBrowser = null + } + } + + export function probeChromium(): Promise { + const bundled = chromium.executablePath() + const executable = + getConfig().browser.executablePath ?? (existsSync(bundled) ? bundled : findSystemChrome()) ?? bundled + if (existsSync(executable)) return Promise.resolve({ state: "available", message: executable }) + return Promise.resolve({ + state: "missing", + message: `Chromium executable not found at ${executable}. Install with \`npx playwright install chromium\`.`, + }) + } + + export function activePage(live: Live): Page { + if (!live.active.isClosed()) return live.active + const page = live.context.pages()[0] + if (!page) throw new Error(`session ${live.name} has no active page`) + live.active = page + return page + } + + export function touch(name: string, url?: string): void { + track(name, url) + } +} diff --git a/packages/kilo-world/src/daemon/build.ts b/packages/kilo-world/src/daemon/build.ts new file mode 100644 index 00000000000..dd0c7210628 --- /dev/null +++ b/packages/kilo-world/src/daemon/build.ts @@ -0,0 +1,108 @@ +import { createHash } from "node:crypto" +import { access, readFile, readdir } from "node:fs/promises" +import { basename, dirname, join, relative, resolve } from "node:path" + +export const STAMP = "world-daemon.source" +export const LOCK = ".world-daemon.lock" +export const ENTRY = "world-daemon.cjs" +export const MANIFEST = "world-daemon.assets.json" +export const LOCK_TIMEOUT_MS = 90_000 +export const BUILD_TIMEOUT_MS = LOCK_TIMEOUT_MS + 60_000 + +export async function fingerprint(root: string): Promise { + const base = resolve(root) + const files = [join(base, "package.json"), join(base, "script", "build-daemon.ts"), join(base, "script", "daemon.ts")] + const walk = async (dir: string): Promise => { + const items = await readdir(dir, { withFileTypes: true }) + return ( + await Promise.all( + items.map(async (item) => (item.isDirectory() ? walk(join(dir, item.name)) : [join(dir, item.name)])), + ) + ).flat() + } + files.push(...(await walk(join(base, "src")))) + const hash = createHash("sha256") + for (const file of files.sort()) { + hash.update(relative(base, file)) + hash.update("\0") + hash.update(await readFile(file)) + hash.update("\0") + } + hash.update("playwright\0") + hash.update(await dependency(base)) + return hash.digest("hex") +} + +async function dependency(root: string): Promise { + const pkg: unknown = JSON.parse(await readFile(join(root, "package.json"), "utf8")) + const spec = record(pkg) && record(pkg.dependencies) ? pkg.dependencies.playwright : undefined + if (typeof spec !== "string") throw new Error("@kilocode/world must declare playwright as a dependency") + for (const dir of parents(root)) { + const text = await readFile(join(dir, "bun.lock"), "utf8").catch(() => undefined) + if (!text) continue + const start = text.indexOf(' "packages/kilo-world": {') + const end = text.indexOf('\n "packages/', start + 1) + const workspace = start >= 0 ? text.slice(start, end >= 0 ? end : undefined) : "" + if (!workspace.includes(`"playwright": ${JSON.stringify(spec)}`)) { + throw new Error("bun.lock does not resolve @kilocode/world's playwright dependency") + } + const resolved = ["playwright", "playwright-core"].map((name) => { + const prefix = ` ${JSON.stringify(name)}: [` + const entry = text.split("\n").find((line) => line.startsWith(prefix)) + if (!entry) throw new Error(`bun.lock does not contain a resolved ${name} package`) + return entry.trim() + }) + const manifest: unknown = JSON.parse(await readFile(join(dir, "package.json"), "utf8")) + const patched = + record(manifest) && record(manifest.patchedDependencies) + ? Object.entries(manifest.patchedDependencies).filter(([name]) => /^(playwright|playwright-core)@/.test(name)) + : [] + const patches = await Promise.all( + patched.map(async ([name, file]) => { + if (typeof file !== "string") throw new Error("bun.lock contains an invalid patch path") + return [name, file, await readFile(join(dir, file), "utf8")] + }), + ) + return JSON.stringify({ spec, resolved, patches }) + } + throw new Error("could not resolve playwright from bun.lock") +} + +function parents(root: string): string[] { + const dirs = [root] + while (true) { + const current = dirs.at(-1)! + const parent = dirname(current) + if (parent === current) break + dirs.push(parent) + } + return dirs +} + +function record(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} + +export async function fresh(dir: string, key: string, entry: string, manifest: string): Promise { + const saved = await readFile(join(dir, STAMP), "utf8") + .then((value) => value.trim()) + .catch(() => "") + if (saved !== key) return false + const files: unknown = await readFile(join(dir, manifest), "utf8") + .then((value): unknown => JSON.parse(value)) + .catch(() => []) + if (!Array.isArray(files) || !files.every((item) => typeof item === "string" && basename(item) === item)) { + return false + } + if (!files.includes(entry) || !files.includes(STAMP)) return false + return ( + await Promise.all( + files.map((file) => + access(join(dir, file)).then( + () => true, + () => false, + ), + ), + ) + ).every(Boolean) +} diff --git a/packages/kilo-world/src/daemon/client.ts b/packages/kilo-world/src/daemon/client.ts new file mode 100644 index 00000000000..ba281e7eca0 --- /dev/null +++ b/packages/kilo-world/src/daemon/client.ts @@ -0,0 +1,540 @@ +import { closeSync, constants, existsSync, openSync, readFileSync } from "node:fs" +import { spawn, type ChildProcess } from "node:child_process" +import { fileURLToPath } from "node:url" +import { dirname, join } from "node:path" +import { getConfig } from "../config" +import type { Action, DaemonConfig, RunOptions, RunResult, WorldConfig } from "../types" +import { parseScript as parseScriptFn } from "../script-parser" +import { DaemonServer } from "./server" +import { isHandshake, isResponse, type DaemonRequest, type DaemonResponse } from "./protocol" +import { BUILD_TIMEOUT_MS, ENTRY, fingerprint, fresh, MANIFEST } from "./build" + +const SCRIPT_HEAD_TIMEOUT_MS = 30_000 +const BUILD_OUTPUT_MAX = 16_384 +const BUILD_FAILURE_COOLDOWN_MS = 30_000 +const starts = new Map>() +let building: { key: string; task: Promise } | null = null +let failed: { key: string; err: Error; at: number } | null = null + +function root(): string { + const here = dirname(fileURLToPath(import.meta.url)) + return join(here, "..", "..") +} + +function paths(): string[] { + const override = process.env["KILO_WORLD_DAEMON_PATH"] + const argv = process.argv[1] + return [ + override, + join(dirname(process.execPath), ENTRY), + argv ? join(dirname(argv), ENTRY) : undefined, + join(root(), "dist", ENTRY), + ].filter((item): item is string => Boolean(item)) +} + +async function entry(opts: DaemonClient.CallOptions): Promise { + const override = process.env["KILO_WORLD_DAEMON_PATH"] + if (override && !existsSync(override)) throw new Error(`KILO_WORLD_DAEMON_PATH does not exist: ${override}`) + const local = join(root(), "dist", ENTRY) + const current = paths().find(existsSync) + if (current && current !== local) return current + const script = join(root(), "script", "build-daemon.ts") + if (existsSync(script) && typeof Bun !== "undefined") { + const key = await fingerprint(root()) + if (await fresh(dirname(local), key, ENTRY, MANIFEST)) return local + if (failed?.key === key && Date.now() - failed.at < BUILD_FAILURE_COOLDOWN_MS) throw failed.err + if (failed?.key === key) failed = null + if (!building || building.key !== key) { + const task = build(script) + .then(() => { + if (failed?.key === key) failed = null + }) + .catch((err: unknown) => { + const error = err instanceof Error ? err : new Error(String(err)) + failed = { key, err: error, at: Date.now() } + throw error + }) + .finally(() => { + if (building?.task === task) building = null + }) + building = { key, task } + } + await wait(building.task, opts.signal) + } + const file = paths().find(existsSync) + if (file) return file + throw new Error( + `kilo-world Node daemon not found; tried:\n${paths() + .map((item) => ` - ${item}`) + .join("\n")}`, + ) +} + +async function build(script: string): Promise { + const bin = Bun.which("bun") ?? process.execPath + const child = spawn(bin, [script], { + detached: process.platform !== "win32", + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, + }) + const state = { output: "", timeout: false } + const append = (data: Buffer | string) => { + state.output = `${state.output}${data.toString()}`.slice(-BUILD_OUTPUT_MAX) + } + child.stdout?.on("data", append) + child.stderr?.on("data", append) + const timer = setTimeout(() => { + state.timeout = true + kill(child) + }, BUILD_TIMEOUT_MS) + const code = await new Promise((resolve, reject) => { + child.once("error", reject) + child.once("close", resolve) + }).finally(() => clearTimeout(timer)) + if (code === 0 && !state.timeout) return + const reason = state.timeout ? `timed out after ${BUILD_TIMEOUT_MS}ms` : `exited ${code ?? "unknown"}` + const details = state.output.trim() + throw new Error(`failed to build kilo-world Node daemon: ${reason}${details ? `\n${details}` : ""}`) +} + +function wait(task: Promise, signal?: AbortSignal): Promise { + if (!signal) return task + signal.throwIfAborted() + return new Promise((resolve, reject) => { + const abort = () => reject(new Error("daemon build aborted")) + signal.addEventListener("abort", abort, { once: true }) + task.then(resolve, reject).finally(() => signal.removeEventListener("abort", abort)) + }) +} + +function runtime(): string { + const override = process.env["KILO_WORLD_NODE"] + if (override) return override + const name = process.platform === "win32" ? "node.exe" : "node" + const dir = dirname(process.execPath) + const bundled = [join(dir, "node-runtime", name), join(dir, name)].find(existsSync) + if (bundled) return bundled + if (process.versions.electron) return process.execPath + if (typeof Bun !== "undefined") return Bun.which("node") ?? name + if (process.release.name === "node") return process.execPath + return name +} + +export namespace DaemonClient { + export type CallOptions = { + timeoutMs?: number + silent?: boolean + signal?: AbortSignal + } + + export function isRunning(sessionID: string): boolean { + return DaemonServer.isRunning(sessionID) + } + + export function handshake(sessionID: string): { + pid: number + startedAt: number + url: string + token: string + } | null { + const path = DaemonServer.handshakePath(sessionID) + if (!existsSync(path)) return null + try { + const data: unknown = JSON.parse(readFileSync(path, "utf8")) + if (!isHandshake(data)) return null + return { pid: data.pid, startedAt: data.startedAt ?? 0, url: data.url, token: data.token } + } catch { + return null + } + } + + export async function ensureRunning(sessionID: string, opts: CallOptions & { idleMs?: number } = {}): Promise { + if (await ping(sessionID)) return + const current = starts.get(sessionID) + if (current) return current + const pending = launch(sessionID, opts).finally(() => { + if (starts.get(sessionID) === pending) starts.delete(sessionID) + }) + starts.set(sessionID, pending) + return pending + } + + async function launch(sessionID: string, opts: CallOptions & { idleMs?: number }): Promise { + const file = await entry(opts) + const bin = runtime() + if (process.env["KILO_WORLD_NODE"] && /[\\/]/.test(bin) && !existsSync(bin)) { + throw new Error(`KILO_WORLD_NODE does not exist: ${bin}`) + } + const logFd = openSync( + DaemonServer.logPath(sessionID), + constants.O_APPEND | constants.O_CREAT | constants.O_WRONLY, + 0o600, + ) + const cfg = getConfig() + const env: NodeJS.ProcessEnv = { + ...process.env, + KILO_WORLD_DAEMON_SESSION: sessionID, + KILO_WORLD_PARENT_PID: String(process.pid), + KILO_WORLD_HOME: cfg.home, + ...(process.versions.electron ? { ELECTRON_RUN_AS_NODE: "1" } : {}), + ...(cfg.browser.executablePath ? { KILO_WORLD_CHROMIUM: cfg.browser.executablePath } : {}), + ...(opts.idleMs !== undefined ? { KILO_WORLD_DAEMON_IDLE_MS: String(opts.idleMs) } : {}), + } + const args = [file, `--session=${sessionID}`, ...(opts.idleMs !== undefined ? [`--idle=${opts.idleMs}`] : [])] + const state: { err?: Error } = {} + const child = (() => { + try { + return spawn(bin, args, { + detached: true, + stdio: ["ignore", logFd, logFd], + env, + windowsHide: true, + }) + } finally { + closeSync(logFd) + } + })() + child.once("error", (err) => { + state.err = err + }) + child.unref() + const start = Date.now() + while (Date.now() - start < SCRIPT_HEAD_TIMEOUT_MS) { + if (opts.signal?.aborted) { + kill(child) + throw new Error("daemon startup aborted") + } + if (state.err) { + kill(child) + throw new Error( + `failed to start kilo-world daemon with ${bin}: ${state.err.message}. Install Node or set KILO_WORLD_NODE to its executable.`, + ) + } + if (await ping(sessionID)) return + await new Promise((r) => setTimeout(r, 50)) + } + kill(child) + // Surface the daemon log path so the user can read why startup failed. + // The log is appended (openSync with "a") so any stderr/stdout from the + // dying daemon process is captured there even when windowsHide is on. + throw new Error( + `kilo-world daemon for session ${sessionID} failed to start within ${SCRIPT_HEAD_TIMEOUT_MS}ms. ` + + `Inspect the daemon log for the failure cause: ${DaemonServer.logPath(sessionID)}`, + ) + } + + async function ping(sessionID: string): Promise { + if (!isRunning(sessionID)) return false + const hs = handshake(sessionID) + if (!hs) return false + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), 500) + return fetch(`${hs.url}/call`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ id: randomId(), verb: "__ping__", args: [], auth: hs.token }), + signal: controller.signal, + }) + .then((res) => res.ok) + .catch(() => false) + .finally(() => clearTimeout(timer)) + } + + export async function call(sessionID: string, req: DaemonRequest, opts: CallOptions = {}): Promise { + await ensureRunning(sessionID, opts) + return send(sessionID, req, opts) + } + + export async function callScript( + sessionID: string, + script: string, + opts: CallOptions = {}, + ): Promise { + const segments = parseScript(script) + const out: DaemonResponse[] = [] + for (const seg of segments) { + out.push(await call(sessionID, { id: randomId(), verb: seg.verb, args: seg.args }, opts)) + if (!out.at(-1)?.ok) break + } + return out + } + + export async function stop(sessionID: string, opts: CallOptions = {}): Promise { + if (!isRunning(sessionID)) return false + try { + const resp = await call( + sessionID, + { id: randomId(), verb: "__shutdown__", args: [] }, + { ...opts, timeoutMs: opts.timeoutMs ?? 5000, silent: true }, + ) + return resp.ok + } catch { + return false + } + } + + export function parseScript(text: string): Action[] { + return parseScriptFn(text) + } + + function randomId(): string { + return Math.random().toString(36).slice(2, 10) + } + + function send(sessionID: string, req: DaemonRequest, opts: CallOptions): Promise { + const hs = DaemonServer.handshake(sessionID) + if (!hs) throw new Error(`daemon handshake missing for session ${sessionID}`) + // Tag every request with the daemon's auth token. The server returns 401 + // on mismatch so an unauthorized peer can't drive the browser. + const authed: DaemonRequest = { ...req, paths: req.paths ?? [], auth: hs.token } + const controller = new AbortController() + const timeout = opts.timeoutMs ?? 60_000 + const state = { timeout: false } + const abort = () => controller.abort() + if (opts.signal?.aborted) controller.abort() + opts.signal?.addEventListener("abort", abort, { once: true }) + const timer = setTimeout(() => { + state.timeout = true + controller.abort() + }, timeout) + return fetch(`${hs.url}/call`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(authed), + signal: controller.signal, + }) + .then(async (res) => { + if (!res.ok) { + if (res.status === 401) throw new Error(`daemon rejected request: unauthorized`) + const text = await res.text().catch(() => "") + throw new Error(`daemon HTTP ${res.status}: ${text.slice(0, 200)}`) + } + const data: unknown = await res.json() + if (!isResponse(data)) throw new Error("daemon returned an invalid response") + return data + }) + .finally(() => { + clearTimeout(timer) + opts.signal?.removeEventListener("abort", abort) + }) + .catch((err: unknown) => { + if (err instanceof Error && err.name === "AbortError") { + if (state.timeout) throw new Error(`daemon call timed out after ${timeout}ms`) + throw new Error("daemon call aborted") + } + throw err + }) + } + + export type StartResult = { + started: boolean + idleMs: number + idleMsRemaining: number + pid?: number + } + + /** + * Manually start (or re-configure) the per-session browser daemon. + * `idleMs` of 0 means "never time out" — the daemon stays alive until + * explicitly stopped via `stop()` or a `daemon.stop` verb. + */ + export async function startDaemon(sessionID: string, opts: { idleMs?: number } = {}): Promise { + const idleMs = normalizeIdleMs(opts.idleMs) + const wasRunning = isRunning(sessionID) + if (wasRunning) { + const resp = await call( + sessionID, + { id: randomId(), verb: "__set_idle__", args: [String(idleMs)] }, + { silent: true, timeoutMs: 5000 }, + ) + const data = (resp.envelope ?? {}) as { + idleTimeoutMs?: number + idleTimeoutRemainingMs?: number + } + const hs = handshake(sessionID) + return { + started: false, + idleMs: data.idleTimeoutMs ?? idleMs, + idleMsRemaining: data.idleTimeoutRemainingMs ?? idleMs, + ...(hs ? { pid: hs.pid } : {}), + } + } + await ensureRunning(sessionID, { idleMs, silent: true }) + const status = await statusOf(sessionID) + return { + started: true, + idleMs: status.idleMs, + idleMsRemaining: status.idleMsRemaining, + ...(status.pid !== undefined ? { pid: status.pid } : {}), + } + } + + export type Status = { + running: boolean + pid?: number + sessionID?: string + runtime?: "node" | "bun" + runtimeVersion?: string + idleMs: number + idleMsRemaining: number + } + + export async function statusOf(sessionID: string): Promise { + if (!isRunning(sessionID)) { + return { running: false, idleMs: 0, idleMsRemaining: 0 } + } + try { + const resp = await call( + sessionID, + { id: randomId(), verb: "__status__", args: [] }, + { silent: true, timeoutMs: 5000 }, + ) + const data = (resp.envelope ?? {}) as { + pid?: number + sessionID?: string + runtime?: "node" | "bun" + runtimeVersion?: string + idleTimeoutMs?: number + idleTimeoutRemainingMs?: number + } + return { + running: true, + ...(data.pid !== undefined ? { pid: data.pid } : {}), + ...(data.sessionID ? { sessionID: data.sessionID } : {}), + ...(data.runtime ? { runtime: data.runtime } : {}), + ...(data.runtimeVersion ? { runtimeVersion: data.runtimeVersion } : {}), + idleMs: data.idleTimeoutMs ?? 0, + idleMsRemaining: data.idleTimeoutRemainingMs ?? 0, + } + } catch { + return { + running: false, + idleMs: 0, + idleMsRemaining: 0, + } + } + } + + function normalizeIdleMs(value: number | undefined): number { + if (value === undefined) return 5 * 60_000 + const n = Math.floor(value) + if (!Number.isFinite(n)) throw new Error("idle timeout must be a finite number") + if (n < 0) throw new Error("idle timeout cannot be negative") + if (n === 0) return 0 + return n + } + + export async function setIdle(sessionID: string, idleMs: number): Promise { + return startDaemon(sessionID, { idleMs: normalizeIdleMs(idleMs) }) + } + + export async function runViaSession(sessionID: string, script: string, opts: RunOptions = {}): Promise { + const segments = parseScript(script) + const startedAt = Date.now() + const results: RunResult["results"] = [] + + for (const seg of segments) { + if (opts.signal?.aborted) throw new Error("world script aborted") + if (seg.verb === "daemon.start") { + const idle = flagString(seg, "--idle") + const data = await startDaemon(sessionID, { idleMs: idle === undefined ? 5 * 60_000 : Number(idle) }) + results.push({ ok: true, verb: seg.verb, args: seg.args, data, durationMs: 0 }) + continue + } + const response = await call( + sessionID, + { + id: randomId(), + verb: seg.verb, + args: seg.args, + ...(opts.directory ? { directory: opts.directory } : {}), + ...(opts.paths ? { paths: opts.paths } : {}), + ...(opts.config ? { config: daemonConfig(opts.config) } : {}), + }, + { silent: true, timeoutMs: opts.timeoutMs, signal: opts.signal }, + ) + const result = responseResult(response, seg) + results.push(result) + if (!result.ok) break + } + + return { ok: results.every((r) => r.ok), durationMs: Date.now() - startedAt, results } + } +} + +function kill(child: ChildProcess): void { + if (child.exitCode !== null || child.pid === undefined) return + if (process.platform === "win32") { + child.kill() + return + } + try { + process.kill(-child.pid, "SIGTERM") + } catch (err) { + if (!child.kill() && err instanceof Error) process.stderr.write(`failed to stop world daemon: ${err.message}\n`) + } +} + +function daemonConfig(cfg: WorldConfig): DaemonConfig { + return { + browser: { + headless: cfg.browser.headless, + antiDetect: cfg.browser.antiDetect, + timeoutMs: cfg.browser.timeoutMs, + viewport: cfg.browser.viewport, + useSystemChrome: cfg.browser.useSystemChrome ?? false, + }, + } +} + +function responseResult(response: DaemonResponse, action: Action): RunResult["results"][number] { + const env = response.envelope + const ok = response.ok && env.ok !== false + const data = record(env.data) ? env.data : undefined + const errors = Array.isArray(env.errors) ? env.errors.filter((item): item is string => typeof item === "string") : [] + const shot = screenshot(env.screenshot) + const refs = Array.isArray(data?.refs) ? data.refs.filter(isRef) : [] + return { + ok, + verb: typeof env.verb === "string" ? env.verb : action.verb, + args: action.args, + data: env.data, + ...(!ok ? { error: errors[0] ?? (typeof env.error === "string" ? env.error : response.message) ?? "unknown" } : {}), + durationMs: typeof env.durationMs === "number" ? env.durationMs : 0, + ...(shot + ? { + screenshot: { + path: shot.path, + bytes: shot.bytes, + mime: shot.mime ?? "image/png", + }, + } + : {}), + ...(env.verb === "snapshot" && refs.length > 0 ? { refs } : {}), + } +} + +function record(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} + +function screenshot(value: unknown): { path: string; bytes: number; mime?: string } | undefined { + if (!record(value) || typeof value.path !== "string" || typeof value.bytes !== "number") return undefined + if (value.mime !== undefined && typeof value.mime !== "string") return undefined + return { path: value.path, bytes: value.bytes, ...(value.mime ? { mime: value.mime } : {}) } +} + +function isRef(value: unknown): value is { ref: string; role: string; name: string; selector?: string } { + if (!record(value)) return false + if (typeof value.ref !== "string" || typeof value.role !== "string" || typeof value.name !== "string") return false + return value.selector === undefined || typeof value.selector === "string" +} + +function flagString(action: { verb: string; args: string[] }, name: string): string | undefined { + for (let i = 0; i < action.args.length; i++) { + const t = action.args[i] + if (t === name) return action.args[i + 1] + if (t?.startsWith(`${name}=`)) return t.slice(name.length + 1) + } + return undefined +} diff --git a/packages/kilo-world/src/daemon/dispatch.ts b/packages/kilo-world/src/daemon/dispatch.ts new file mode 100644 index 00000000000..da910f2810c --- /dev/null +++ b/packages/kilo-world/src/daemon/dispatch.ts @@ -0,0 +1,344 @@ +import type { Action, ActionResult, RunOptions } from "../types" +import { existsSync, readFileSync } from "node:fs" +import { DaemonServer } from "./server" +import { Status } from "../commands/browser/status" +import { Navigate } from "../commands/browser/navigate" +import { Snapshot } from "../commands/browser/snapshot" +import { Click } from "../commands/browser/click" +import { Type } from "../commands/browser/type" +import { Fill } from "../commands/browser/fill" +import { PressKey } from "../commands/browser/press-key" +import { Hover } from "../commands/browser/hover" +import { Drag } from "../commands/browser/drag" +import { Scroll } from "../commands/browser/scroll" +import { Screenshot } from "../commands/browser/screenshot" +import { Evaluate } from "../commands/browser/evaluate" +import { WaitFor } from "../commands/browser/wait-for" +import { Tabs } from "../commands/browser/tabs" +import { Cookies } from "../commands/browser/cookies" +import { Close } from "../commands/browser/close" +import { Runner } from "../core/browser/runner" +import { resolvePath } from "../path" + +// Keep the command graph explicit so Bun includes Playwright and every command +// in the Kilo executable that is reused for daemon mode. +export async function dispatch(action: Action, opts: RunOptions = {}): Promise { + opts.signal?.throwIfAborted() + const task = execute(action).then((result) => { + opts.signal?.throwIfAborted() + return result + }) + if (!opts.signal) return task + const signal = opts.signal + const state: { reject?: (err: Error) => void } = {} + const stop = () => { + state.reject?.(new Error("world action aborted")) + } + const abort = new Promise((_, reject) => { + state.reject = reject + signal.addEventListener("abort", stop, { once: true }) + if (signal.aborted) stop() + }) + return Promise.race([task, abort]).finally(() => signal.removeEventListener("abort", stop)) +} + +async function execute(action: Action): Promise { + const startedAt = Date.now() + try { + if (flagString(action, "--session") !== undefined) throw new Error("--session is reserved for Kilo") + if (action.config) await Runner.configure(action.config) + const verb = action.verb + if (verb === "status") { + return ok(action, startedAt, await Status.run()) + } + if (verb === "daemon.status") { + return { + ok: true, + verb, + args: action.args, + data: DaemonServer.status(), + durationMs: Date.now() - startedAt, + } + } + if (verb === "daemon.stop") { + return ok(action, startedAt, { stopping: true }) + } + if (verb === "daemon.list") { + const { ensureHome, getConfig } = await import("../config") + const home = ensureHome(getConfig().home) + const { readdirSync } = await import("node:fs") + const files = readdirSync(home).filter((f) => f.startsWith("daemon-") && f.endsWith(".pid")) + const sessions: Array<{ sessionID: string; pid: number; startedAt: number }> = [] + for (const f of files) { + const id = f.slice("daemon-".length, -".pid".length) + const hs = DaemonServer.handshake(id) + if (hs) sessions.push({ sessionID: hs.sessionID ?? id, pid: hs.pid, startedAt: hs.startedAt }) + } + return ok(action, startedAt, { sessions }) + } + if (verb === "navigate") { + const url = required(action, "--url") + const timeout = numberFlag(action, "--timeout") + return ok( + action, + startedAt, + await Navigate.run({ + url, + ...(flagString(action, "--wait") ? { wait: flagString(action, "--wait") } : {}), + ...(timeout !== undefined ? { timeoutMs: timeout } : {}), + }), + ) + } + if (verb === "snapshot") { + const data = await Snapshot.run() + return { ...ok(action, startedAt, data), refs: data.refs } + } + if (verb === "click") { + const timeout = numberFlag(action, "--timeout") + return ok( + action, + startedAt, + await Click.run({ + ...(flagString(action, "--ref") ? { ref: flagString(action, "--ref") } : {}), + ...(flagString(action, "--selector") ? { selector: flagString(action, "--selector") } : {}), + ...(timeout !== undefined ? { timeoutMs: timeout } : {}), + }), + ) + } + if (verb === "type") { + const text = required(action, "--text") + const delay = numberFlag(action, "--delay") + return ok( + action, + startedAt, + await Type.run({ + text, + ...(flagString(action, "--ref") ? { ref: flagString(action, "--ref") } : {}), + ...(flagString(action, "--selector") ? { selector: flagString(action, "--selector") } : {}), + ...(delay !== undefined ? { delay } : {}), + }), + ) + } + if (verb === "fill") { + const value = required(action, "--value") + return ok( + action, + startedAt, + await Fill.run({ + value, + ...(flagString(action, "--ref") ? { ref: flagString(action, "--ref") } : {}), + ...(flagString(action, "--selector") ? { selector: flagString(action, "--selector") } : {}), + ...(hasFlag(action, "--force") ? { force: true } : {}), + }), + ) + } + if (verb === "press-key") { + return ok( + action, + startedAt, + await PressKey.run({ + chord: required(action, "--chord"), + }), + ) + } + if (verb === "hover") { + return ok( + action, + startedAt, + await Hover.run({ + ...(flagString(action, "--ref") ? { ref: flagString(action, "--ref") } : {}), + ...(flagString(action, "--selector") ? { selector: flagString(action, "--selector") } : {}), + }), + ) + } + if (verb === "drag") { + return ok( + action, + startedAt, + await Drag.run({ + from: required(action, "--from"), + to: required(action, "--to"), + }), + ) + } + if (verb === "scroll") { + return ok( + action, + startedAt, + await Scroll.run({ + dx: numberFlag(action, "--dx") ?? 0, + dy: numberFlag(action, "--dy") ?? 0, + ...(flagString(action, "--ref") ? { ref: flagString(action, "--ref") } : {}), + ...(flagString(action, "--selector") ? { selector: flagString(action, "--selector") } : {}), + }), + ) + } + if (verb === "screenshot") { + const typeRaw = flagString(action, "--type") + const wait = numberFlag(action, "--wait") + const quality = numberFlag(action, "--quality") + const data = await Screenshot.run({ + out: authorized(action, required(action, "--out")), + ...(hasFlag(action, "--full") ? { full: true } : {}), + ...(wait !== undefined ? { waitMs: wait } : {}), + ...(typeRaw === "png" || typeRaw === "jpeg" ? { type: typeRaw } : {}), + ...(quality !== undefined ? { quality } : {}), + }) + return { + ok: true, + verb: action.verb, + args: action.args, + data, + durationMs: Date.now() - startedAt, + screenshot: { path: data.out, bytes: data.bytes, mime: data.mime }, + } + } + if (verb === "evaluate") { + const js = await resolveEvaluateJs(action) + return ok( + action, + startedAt, + await Evaluate.run({ + js, + }), + ) + } + if (verb === "wait-for") { + const timeout = numberFlag(action, "--timeout") + return ok( + action, + startedAt, + await WaitFor.run({ + ...(flagString(action, "--selector") ? { selector: flagString(action, "--selector") } : {}), + ...(flagString(action, "--text") ? { text: flagString(action, "--text") } : {}), + ...(flagString(action, "--url") ? { url: flagString(action, "--url") } : {}), + ...(timeout !== undefined ? { timeoutMs: timeout } : {}), + }), + ) + } + if (verb === "tabs") return ok(action, startedAt, await dispatchTabs(action)) + if (verb === "cookies") return ok(action, startedAt, await dispatchCookies(action)) + if (verb === "close") { + return ok(action, startedAt, await Close.run()) + } + if (verb === "shutdown") { + await Runner.shutdown() + return ok(action, startedAt, { shutdown: "browser" }) + } + throw new Error(`unknown verb: ${verb}`) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + return { + ok: false, + verb: action.verb, + args: action.args, + error: message, + durationMs: Date.now() - startedAt, + } + } +} + +async function dispatchTabs(action: Action): Promise { + const sub = action.args[0] ?? "list" + if (sub === "list") return Tabs.list() + if (sub === "open") { + const url = flagString(action, "--url") + return Tabs.open({ + ...(url ? { url } : {}), + }) + } + if (sub === "select") { + return Tabs.select({ + index: requiredNumber(action, "--index"), + }) + } + if (sub === "close") { + const idx = flagString(action, "--index") + return Tabs.close({ + ...(idx !== undefined ? { index: requiredNumber(action, "--index") } : {}), + }) + } + throw new Error(`unknown tabs subcommand: ${sub}`) +} + +async function dispatchCookies(action: Action): Promise { + const sub = action.args[0] ?? "get" + if (sub === "get") { + return Cookies.get({ domain: required(action, "--domain") }) + } + if (sub === "set") { + return Cookies.set({ + name: required(action, "--name"), + value: required(action, "--value"), + domain: required(action, "--domain"), + ...(flagString(action, "--path") ? { path: flagString(action, "--path") } : {}), + }) + } + if (sub === "clear") { + return Cookies.clear({ + ...(flagString(action, "--domain") ? { domain: flagString(action, "--domain") } : {}), + }) + } + throw new Error(`unknown cookies subcommand: ${sub}`) +} + +function ok(action: Action, startedAt: number, data: unknown): ActionResult { + return { + ok: true, + verb: action.verb, + args: action.args, + data, + durationMs: Date.now() - startedAt, + } +} + +async function resolveEvaluateJs(action: Action): Promise { + const inline = flagString(action, "--js") + if (inline !== undefined) return inline + const file = flagString(action, "--js-file") + if (file === undefined) throw new Error("evaluate requires --js or --js-file ") + const target = authorized(action, file) + if (!existsSync(target)) throw new Error(`--js-file not found: ${target}`) + return readFileSync(target, "utf8") +} + +function authorized(action: Action, value: string): string { + const target = resolvePath(value, action.directory) + if (action.paths === undefined) return target + const allowed = action.paths.some((item) => resolvePath(item, action.directory) === target) + if (!allowed) throw new Error(`path was not authorized: ${target}`) + return target +} + +function flagString(action: Action, name: string): string | undefined { + for (let i = 0; i < action.args.length; i++) { + const t = action.args[i] + if (t === name) return action.args[i + 1] + if (t?.startsWith(`${name}=`)) return t.slice(name.length + 1) + } + return undefined +} + +function hasFlag(action: Action, name: string): boolean { + return action.args.includes(name) +} + +function required(action: Action, name: string): string { + const v = flagString(action, name) + if (v === undefined) throw new Error(`missing required flag: ${name}`) + return v +} + +function numberFlag(action: Action, name: string): number | undefined { + const value = flagString(action, name) + if (value === undefined) return undefined + const number = Number(value) + if (!Number.isFinite(number)) throw new Error(`${name} must be a finite number`) + return number +} + +function requiredNumber(action: Action, name: string): number { + const value = numberFlag(action, name) + if (value === undefined) throw new Error(`missing required flag: ${name}`) + return value +} diff --git a/packages/kilo-world/src/daemon/entry.ts b/packages/kilo-world/src/daemon/entry.ts new file mode 100644 index 00000000000..99148b96cc3 --- /dev/null +++ b/packages/kilo-world/src/daemon/entry.ts @@ -0,0 +1,7 @@ +import { start } from "./runtime" + +start().catch((err: unknown) => { + const message = err instanceof Error ? err.message : String(err) + process.stderr.write(`daemon fatal: ${message}\n`) + process.exit(1) +}) diff --git a/packages/kilo-world/src/daemon/protocol.ts b/packages/kilo-world/src/daemon/protocol.ts new file mode 100644 index 00000000000..12e8c6ec223 --- /dev/null +++ b/packages/kilo-world/src/daemon/protocol.ts @@ -0,0 +1,104 @@ +import type { DaemonConfig } from "../types" + +export type DaemonRequest = { + id: string + verb: string + args: string[] + directory?: string + paths?: string[] + config?: DaemonConfig + /** + * Per-daemon auth token (32-byte hex, 64 chars). Required on every request. + * Generated by the daemon at startup and written to the handshake file + * alongside `url`. Without a valid token the daemon rejects the request — + * prevents any other local process on a multi-user system (or any other + * process running as your user) from driving the browser by POSTing to + * the daemon's HTTP endpoint. + */ + auth?: string +} + +export type DaemonResponse = { + id: string + ok: boolean + envelope: Record + message?: string +} + +export type DaemonLifecycleResponse = { + id: string + ok: boolean + message?: string +} + +export type DaemonHandshake = { + pid: number + version: string + startedAt: number + idleTimeoutMs: number + sessionID: string + /** + * Base URL of the per-session HTTP endpoint, e.g. `http://127.0.0.1:54321`. + * Single source of truth — clients POST to `${url}/call` and need not + * reconstruct host/port themselves. + */ + url: string + /** + * Per-daemon auth token. Clients must echo this on every request. + * See `DaemonRequest.auth`. + */ + token: string +} + +function record(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} + +export function isHandshake(value: unknown): value is DaemonHandshake { + if (!record(value)) return false + return ( + typeof value.pid === "number" && + typeof value.version === "string" && + typeof value.startedAt === "number" && + typeof value.idleTimeoutMs === "number" && + typeof value.sessionID === "string" && + typeof value.url === "string" && + typeof value.token === "string" + ) +} + +export function isRequest(value: unknown): value is DaemonRequest { + if (!record(value)) return false + if (typeof value.id !== "string" || typeof value.verb !== "string") return false + if (!Array.isArray(value.args) || !value.args.every((arg) => typeof arg === "string")) return false + if (value.directory !== undefined && typeof value.directory !== "string") return false + if ( + value.paths !== undefined && + (!Array.isArray(value.paths) || !value.paths.every((item) => typeof item === "string")) + ) + return false + if (value.auth !== undefined && typeof value.auth !== "string") return false + return value.config === undefined || config(value.config) +} + +function config(value: unknown): value is DaemonConfig { + if (!record(value)) return false + if (Object.keys(value).some((key) => key !== "browser")) return false + if (value.browser === undefined) return true + if (!record(value.browser)) return false + const keys = ["headless", "antiDetect", "timeoutMs", "viewport", "useSystemChrome"] + if (Object.keys(value.browser).some((key) => !keys.includes(key))) return false + if (value.browser.headless !== undefined && typeof value.browser.headless !== "boolean") return false + if (value.browser.antiDetect !== undefined && typeof value.browser.antiDetect !== "boolean") return false + if (value.browser.useSystemChrome !== undefined && typeof value.browser.useSystemChrome !== "boolean") return false + if (value.browser.timeoutMs !== undefined && typeof value.browser.timeoutMs !== "number") return false + if (value.browser.viewport === undefined) return true + if (!record(value.browser.viewport)) return false + return typeof value.browser.viewport.width === "number" && typeof value.browser.viewport.height === "number" +} + +export function isResponse(value: unknown): value is DaemonResponse { + if (!record(value)) return false + if (typeof value.id !== "string" || typeof value.ok !== "boolean" || !record(value.envelope)) return false + return value.message === undefined || typeof value.message === "string" +} diff --git a/packages/kilo-world/src/daemon/runtime.ts b/packages/kilo-world/src/daemon/runtime.ts new file mode 100644 index 00000000000..3691048ec49 --- /dev/null +++ b/packages/kilo-world/src/daemon/runtime.ts @@ -0,0 +1,22 @@ +import { Runner } from "../core/browser/runner" +import { dispatch } from "./dispatch" +import { DaemonServer, setDispatch, setShutdown } from "./server" + +export async function start(): Promise { + const sessionArg = process.argv.find((arg) => arg.startsWith("--session=")) + const idleArg = process.argv.find((arg) => arg.startsWith("--idle=")) + const session = sessionArg?.slice("--session=".length) ?? "default" + if (!session) throw new Error("--session= is required") + const env = process.env["KILO_WORLD_DAEMON_IDLE_MS"] + const raw = idleArg?.slice("--idle=".length) ?? env + const idle = raw === undefined ? undefined : Number(raw) + if (idle !== undefined && !Number.isFinite(idle)) throw new Error("idle timeout must be a finite number") + setDispatch(dispatch) + setShutdown(() => Runner.shutdown()) + await DaemonServer.start({ + sessionID: session, + silent: process.env["KILO_WORLD_DAEMON_SILENT"] === "1", + ...(Number.isFinite(idle) ? { idleTimeoutMs: idle } : {}), + }) + await new Promise(() => {}) +} diff --git a/packages/kilo-world/src/daemon/server.ts b/packages/kilo-world/src/daemon/server.ts new file mode 100644 index 00000000000..1aca05d0729 --- /dev/null +++ b/packages/kilo-world/src/daemon/server.ts @@ -0,0 +1,493 @@ +import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs" +import { randomBytes, timingSafeEqual } from "node:crypto" +import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http" +import { join } from "node:path" +import { ensureHome, getConfig } from "../config" +import type { RunOptions } from "../types" +import { isHandshake, isRequest, type DaemonHandshake, type DaemonRequest, type DaemonResponse } from "./protocol" + +export type DispatchFn = (req: DaemonRequest, opts?: RunOptions) => Promise> + +let installedDispatch: DispatchFn | null = null +let installedShutdown: (() => Promise) | null = null +let currentServer: Server | null = null + +export function setDispatch(fn: DispatchFn): void { + installedDispatch = fn +} + +export function setShutdown(fn: () => Promise): void { + installedShutdown = fn +} + +const IDLE_TIMEOUT_MS_DEFAULT = 5 * 60_000 +const SERVER_CLOSE_TIMEOUT_MS = 2000 +const BROWSER_CLOSE_TIMEOUT_MS = 5000 + +const NEVER_TIMEOUT = 0 + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +function authorized(input: string, expected: string): boolean { + const left = Buffer.from(input) + const right = Buffer.from(expected) + return left.length === right.length && timingSafeEqual(left, right) +} + +function remove(file: string): void { + try { + unlinkSync(file) + } catch (err) { + if (err instanceof Error && "code" in err && err.code === "ENOENT") return + throw err + } +} + +export namespace DaemonServer { + export type Options = { + sessionID: string + idleTimeoutMs?: number + silent?: boolean + } + + function safeSession(id: string): string { + return id.replace(/[^a-zA-Z0-9_.-]/g, "_").slice(0, 96) || "default" + } + + export function pidPath(sessionID: string): string { + return join(ensureHome(getConfig().home), `daemon-${safeSession(sessionID)}.pid`) + } + + export function handshakePath(sessionID: string): string { + return join(ensureHome(getConfig().home), `daemon-${safeSession(sessionID)}.json`) + } + + export function logPath(sessionID: string): string { + return join(ensureHome(getConfig().home), `daemon-${safeSession(sessionID)}.log`) + } + + export function handshake(sessionID: string): { + pid: number + startedAt: number + sessionID?: string + url: string + token: string + } | null { + const path = handshakePath(sessionID) + if (!existsSync(path)) return null + try { + const data: unknown = JSON.parse(readFileSync(path, "utf8")) + if (!isHandshake(data)) return null + return { + pid: data.pid, + startedAt: data.startedAt ?? 0, + ...(data.sessionID ? { sessionID: data.sessionID } : {}), + url: data.url, + token: data.token, + } + } catch { + return null + } + } + + function log(line: string, silent: boolean): void { + if (!silent) process.stderr.write(`[kilo-world daemon] ${line}\n`) + } + + export function isRunning(sessionID: string): boolean { + const pidFile = pidPath(sessionID) + if (!existsSync(pidFile)) return false + const pid = (() => { + try { + return Number(readFileSync(pidFile, "utf8").trim()) + } catch { + return Number.NaN + } + })() + if (!Number.isFinite(pid)) return false + const data = handshake(sessionID) + if (!data || data.pid !== pid) return false + try { + process.kill(pid, 0) + return true + } catch (err) { + if (err instanceof Error && "code" in err && err.code === "EPERM") return true + return false + } + } + + export function clear(sessionID: string, owner?: number): void { + if (owner !== undefined) { + const handshake = DaemonServer.handshake(sessionID) + if (handshake && handshake.pid !== owner) return + } + for (const path of [pidPath(sessionID), handshakePath(sessionID)]) { + remove(path) + } + } + + function claim(sessionID: string): void { + const file = pidPath(sessionID) + try { + writeFileSync(file, String(process.pid), { flag: "wx", mode: 0o600 }) + } catch (err) { + if (!(err instanceof Error) || !("code" in err) || err.code !== "EEXIST") throw err + const pid = (() => { + try { + return Number(readFileSync(file, "utf8").trim()) + } catch { + return Number.NaN + } + })() + if (Number.isFinite(pid)) { + try { + process.kill(pid, 0) + throw new Error(`kilo-world daemon for session ${sessionID} is already starting (pid ${pid})`) + } catch (cause) { + if (!(cause instanceof Error) || !("code" in cause)) throw cause + if (cause.code === "EPERM") { + throw new Error(`kilo-world daemon for session ${sessionID} is already starting (pid ${pid})`) + } + if (cause.code !== "ESRCH") throw cause + } + } + remove(file) + writeFileSync(file, String(process.pid), { flag: "wx", mode: 0o600 }) + } + remove(handshakePath(sessionID)) + } + + let shutdownTimer: NodeJS.Timeout | null = null + let lastActivityAt = Date.now() + let currentIdleTimeoutMs = IDLE_TIMEOUT_MS_DEFAULT + let activeRequests = 0 + let stopping: Promise | null = null + let watcher: NodeJS.Timeout | null = null + + function currentRemainingMs(): number { + if (currentIdleTimeoutMs === NEVER_TIMEOUT) return 0 + if (activeRequests > 0) return currentIdleTimeoutMs + return Math.max(0, currentIdleTimeoutMs - (Date.now() - lastActivityAt)) + } + + export function status() { + return { + running: true, + pid: process.pid, + runtime: typeof Bun === "undefined" ? "node" : "bun", + runtimeVersion: process.version, + sessionID: process.env["KILO_WORLD_DAEMON_SESSION"] ?? currentSessionIDForActivity, + uptimeMs: process.uptime() * 1000, + idleTimeoutMs: currentIdleTimeoutMs, + idleTimeoutRemainingMs: currentRemainingMs(), + } + } + + async function shutdown(sessionID: string, silent: boolean): Promise { + if (stopping) return stopping + stopping = (async () => { + try { + if (shutdownTimer) clearTimeout(shutdownTimer) + if (watcher) clearInterval(watcher) + const server = currentServer + currentServer = null + if (server) { + server.closeAllConnections() + await Promise.race([ + new Promise((resolve) => server.close(() => resolve())), + delay(SERVER_CLOSE_TIMEOUT_MS), + ]) + } + await Promise.race([ + installedShutdown?.().catch((err) => log(`browser shutdown failed: ${String(err)}`, silent)) ?? + Promise.resolve(), + delay(BROWSER_CLOSE_TIMEOUT_MS), + ]) + } finally { + try { + clear(sessionID, process.pid) + } catch (err) { + log(`state cleanup failed: ${String(err)}`, silent) + } + process.exit(0) + } + })() + return stopping + } + + function scheduleShutdown(sessionID: string, delayMs: number, silent: boolean): void { + if (shutdownTimer) clearTimeout(shutdownTimer) + if (delayMs === NEVER_TIMEOUT) { + log(`idle timer disabled for session ${sessionID} (never times out)`, silent) + return + } + log(`idle timer reset for session ${sessionID}: ${delayMs}ms`, silent) + lastActivityAt = Date.now() + shutdownTimer = setTimeout(() => { + log(`idle timeout for session ${sessionID}, shutting down`, silent) + void shutdown(sessionID, silent) + }, delayMs) + } + + function beginActivity(): void { + activeRequests++ + if (shutdownTimer) clearTimeout(shutdownTimer) + } + + function endActivity(): void { + activeRequests = Math.max(0, activeRequests - 1) + lastActivityAt = Date.now() + if (activeRequests > 0 || currentIdleTimeoutMs === NEVER_TIMEOUT || stopping) return + scheduleShutdown(currentSessionIDForActivity, currentIdleTimeoutMs, true) + } + + let currentSessionIDForActivity = "default" + + function applyIdleMs( + sessionID: string, + idleMs: number, + silent: boolean, + ): { idleTimeoutMs: number; idleTimeoutRemainingMs: number } { + if (!Number.isFinite(idleMs)) throw new Error("idle timeout must be a finite number") + if (idleMs < 0) throw new Error("idle timeout cannot be negative") + const normalized = Math.floor(idleMs) + if (normalized === NEVER_TIMEOUT) { + currentIdleTimeoutMs = NEVER_TIMEOUT + if (shutdownTimer) clearTimeout(shutdownTimer) + log(`idle timeout for session ${sessionID} set to never`, silent) + } else { + currentIdleTimeoutMs = normalized + scheduleShutdown(sessionID, normalized, silent) + } + return { idleTimeoutMs: currentIdleTimeoutMs, idleTimeoutRemainingMs: currentRemainingMs() } + } + + // Per-daemon auth token. Generated at `start()` and written to the handshake + // file. Every inbound request must include it; otherwise the daemon returns + // 401. This stops any other local process on a multi-user machine (or any + // process running as the same user) from driving the browser by POSTing to + // the daemon's HTTP endpoint. + let currentAuthToken: string | null = null + + function readJsonBody(req: IncomingMessage): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = [] + let total = 0 + const limit = 16 * 1024 * 1024 + req.on("data", (chunk: Buffer) => { + total += chunk.length + if (total > limit) { + reject(new Error(`request body exceeds ${limit} bytes`)) + req.destroy() + return + } + chunks.push(chunk) + }) + req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8"))) + req.on("error", reject) + }) + } + + function writeJson(res: ServerResponse, status: number, body: unknown): void { + const payload = `${JSON.stringify(body)}\n` + res.writeHead(status, { + "content-type": "application/json", + "content-length": Buffer.byteLength(payload), + }) + res.end(payload) + } + + async function handleCall(req: IncomingMessage, res: ServerResponse, sessionID: string): Promise { + let raw: string + try { + raw = await readJsonBody(req) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + const resp: DaemonResponse = { id: "parse", ok: false, envelope: {}, message: `bad body: ${message}` } + writeJson(res, 400, resp) + return + } + let value: unknown + try { + value = JSON.parse(raw) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + const resp: DaemonResponse = { id: "parse", ok: false, envelope: {}, message: `bad json: ${message}` } + writeJson(res, 400, resp) + return + } + if (!isRequest(value)) { + writeJson(res, 400, { id: "parse", ok: false, envelope: {}, message: "invalid request" }) + return + } + const action = value + if (!currentAuthToken || !action.auth || !authorized(action.auth, currentAuthToken)) { + writeJson(res, 401, { id: action.id ?? "auth", ok: false, envelope: {}, message: "unauthorized" }) + return + } + + beginActivity() + const controller = new AbortController() + req.once("aborted", () => controller.abort()) + res.once("finish", endActivity) + res.once("close", () => { + if (res.writableFinished) return + controller.abort() + endActivity() + }) + + if (action.verb === "__ping__") { + writeJson(res, 200, { id: action.id, ok: true, envelope: {}, message: "pong" }) + return + } + if (action.verb === "__shutdown__") { + const resp: DaemonResponse = { id: action.id, ok: true, envelope: {}, message: "shutting down" } + writeJson(res, 200, resp) + setImmediate(() => { + void shutdown(sessionID, true) + }) + return + } + if (action.verb === "__status__") { + const resp: DaemonResponse = { + id: action.id, + ok: true, + envelope: { + ok: true, + ...status(), + }, + } + writeJson(res, 200, resp) + return + } + if (action.verb === "__set_idle__") { + const idleMs = Number(action.args[0] ?? IDLE_TIMEOUT_MS_DEFAULT) + const out = applyIdleMs(sessionID, idleMs, true) + writeJson(res, 200, { id: action.id, ok: true, envelope: { ok: true, ...out } }) + return + } + try { + if (!installedDispatch) + throw new Error("kilo-world daemon: dispatch not installed (entry.ts forgot to call setDispatch)") + const envelope = await installedDispatch(action, { signal: controller.signal }) + const resp: DaemonResponse = { + id: action.id, + ok: envelope.ok !== false, + envelope, + } + writeJson(res, 200, resp) + if (action.verb === "daemon.stop") setImmediate(() => void shutdown(sessionID, true)) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + const resp: DaemonResponse = { + id: action.id, + ok: false, + envelope: { + ok: false, + tool: "browser", + verb: action.verb, + duration_ms: 0, + warnings: [], + errors: [message], + reason: message.length > 80 ? message.slice(0, 77) + "..." : message, + }, + } + writeJson(res, 200, resp) + } + } + + export async function start(opts: Options): Promise { + const rawIdle = opts.idleTimeoutMs ?? IDLE_TIMEOUT_MS_DEFAULT + if (!Number.isFinite(rawIdle)) throw new Error("idle timeout must be a finite number") + if (rawIdle < 0) throw new Error("idle timeout cannot be negative") + const idleTimeoutMs = Math.floor(rawIdle) + const silent = opts.silent ?? false + const sessionID = opts.sessionID + currentSessionIDForActivity = sessionID + currentIdleTimeoutMs = idleTimeoutMs + lastActivityAt = Date.now() + mkdirSync(ensureHome(getConfig().home), { recursive: true }) + if (isRunning(sessionID)) { + throw new Error(`kilo-world daemon for session ${sessionID} is already running`) + } + claim(sessionID) + currentAuthToken = randomBytes(32).toString("hex") + const server = createServer((req, res) => { + if (req.method === "POST" && req.url === "/call") { + void handleCall(req, res, sessionID).catch((err) => { + const message = err instanceof Error ? err.message : String(err) + try { + writeJson(res, 500, { id: "internal", ok: false, envelope: {}, message }) + } catch (writeErr) { + res.destroy(writeErr instanceof Error ? writeErr : undefined) + } + }) + return + } + writeJson(res, 404, { + id: "not-found", + ok: false, + envelope: {}, + message: `no route for ${req.method} ${req.url}`, + }) + }) + currentServer = server + const host = "127.0.0.1" + const port = await new Promise((resolve, reject) => { + server.once("error", reject) + server.listen({ host, port: 0, exclusive: false }, () => { + server.off("error", reject) + const addr = server.address() + if (addr && typeof addr === "object") resolve(addr.port) + else reject(new Error("server bound but no address returned")) + }) + }).catch((err) => { + currentServer = null + clear(sessionID, process.pid) + throw err + }) + const url = `http://${host}:${port}` + const handshake: DaemonHandshake = { + pid: process.pid, + version: "0.1.0", + startedAt: Date.now(), + idleTimeoutMs, + sessionID, + url, + token: currentAuthToken, + } + writeFileSync(handshakePath(sessionID), JSON.stringify(handshake, null, 2), { mode: 0o600 }) + log( + `listening for session ${sessionID} at ${url} (pid=${process.pid}, idle=${ + idleTimeoutMs === NEVER_TIMEOUT ? "never" : `${idleTimeoutMs}ms` + })`, + silent, + ) + if (idleTimeoutMs !== NEVER_TIMEOUT) { + scheduleShutdown(sessionID, idleTimeoutMs, silent) + } + process.on("SIGTERM", () => { + log("SIGTERM, shutting down", silent) + void shutdown(sessionID, silent) + }) + process.on("SIGINT", () => { + log("SIGINT, shutting down", silent) + void shutdown(sessionID, silent) + }) + process.on("exit", () => clear(sessionID, process.pid)) + const parent = Number(process.env["KILO_WORLD_PARENT_PID"]) + if (Number.isFinite(parent) && parent > 0) { + watcher = setInterval(() => { + try { + process.kill(parent, 0) + } catch { + void shutdown(sessionID, silent) + } + }, 1000) + watcher.unref() + } + return server + } +} diff --git a/packages/kilo-world/src/index.ts b/packages/kilo-world/src/index.ts new file mode 100644 index 00000000000..61b240c3516 --- /dev/null +++ b/packages/kilo-world/src/index.ts @@ -0,0 +1,64 @@ +import { defaultConfig, ensureHome, getConfig, setConfig } from "./config" +import { DaemonClient } from "./daemon/client" +import { dispatch } from "./daemon/dispatch" +import { parseScript as parseScriptFn } from "./script-parser" +import type { Action, RunOptions, RunResult, WorldConfig, WorldConfigPatch } from "./types" + +export { defaultConfig, ensureHome, getConfig, setConfig } +export { DaemonClient } +export { parseScript } from "./script-parser" +export { resolvePath } from "./path" +export type { Action, RunOptions, RunResult, WorldConfig } +export * from "./types" + +export namespace World { + export function configure(patch: WorldConfigPatch): WorldConfig { + return setConfig(patch) + } + + export function currentConfig(): WorldConfig { + return getConfig() + } + + export const parseScript = (text: string): Action[] => parseScriptFn(text) + + export async function run(script: string, opts: RunOptions = {}): Promise { + const actions = parseScript(script) + const startedAt = Date.now() + const results: RunResult["results"] = [] + let allOk = true + for (const action of actions) { + opts.signal?.throwIfAborted() + const result = await dispatch(action, opts) + results.push(result) + if (!result.ok) { + allOk = false + break + } + } + return { ok: allOk, durationMs: Date.now() - startedAt, results } + } + + export async function runForSession(sessionID: string, script: string, opts: RunOptions = {}): Promise { + return DaemonClient.runViaSession(sessionID, script, opts) + } + + export async function startDaemon( + sessionID: string, + opts: { idleMs?: number } = {}, + ): Promise { + return DaemonClient.startDaemon(sessionID, opts) + } + + export async function setDaemonIdle(sessionID: string, idleMs: number): Promise { + return DaemonClient.setIdle(sessionID, idleMs) + } + + export async function daemonStatus(sessionID: string): Promise { + return DaemonClient.statusOf(sessionID) + } + + export async function stopDaemon(sessionID: string): Promise { + return DaemonClient.stop(sessionID) + } +} diff --git a/packages/kilo-world/src/path.ts b/packages/kilo-world/src/path.ts new file mode 100644 index 00000000000..4262379a855 --- /dev/null +++ b/packages/kilo-world/src/path.ts @@ -0,0 +1,18 @@ +import { homedir } from "node:os" +import path from "node:path" + +export function resolvePath(file: string, dir?: string): string { + const home = file === "~" ? homedir() : /^[~][\\/]/.test(file) ? path.join(homedir(), file.slice(2)) : file + const expanded = home.replace(/%([^%]+)%|\$\{([^}]+)\}|\$([a-zA-Z_][a-zA-Z0-9_]*)/g, (_, win, brace, unix) => { + const key = win ?? brace ?? unix + const value = process.env[key] ?? (win ? env(key) : undefined) + return value ?? _ + }) + if (path.isAbsolute(expanded) || !dir) return path.normalize(expanded) + return path.resolve(dir, expanded) +} + +function env(key: string): string | undefined { + const name = Object.keys(process.env).find((item) => item.toLowerCase() === key.toLowerCase()) + return name ? process.env[name] : undefined +} diff --git a/packages/kilo-world/src/script-parser.ts b/packages/kilo-world/src/script-parser.ts new file mode 100644 index 00000000000..e209637b427 --- /dev/null +++ b/packages/kilo-world/src/script-parser.ts @@ -0,0 +1,97 @@ +export type Action = { + verb: string + args: string[] +} + +/** + * Parse a ;-separated script of browser actions. + * + * The parser is quote-aware: `;` inside `'…'`, `"…"`, or `` `…` `` is preserved + * as a regular character. Inside quoted strings, a backslash only escapes + * the active quote character or another backslash, so ordinary Windows path + * separators are preserved. Unquoted whitespace separates tokens. + * + * JavaScript passed to `evaluate --js` must be quoted when it contains + * whitespace or semicolons. `--js-file` accepts one path token. + */ +export function parseScript(text: string): Action[] { + const actions: Action[] = [] + let verb: string | null = null + let args: string[] = [] + let current = "" + let quote: '"' | "'" | "`" | null = null + let escape = false + let started = false + + const flushToken = () => { + if (!started) return + if (verb === null) { + verb = current + } else { + args.push(current) + } + current = "" + started = false + } + const flushVerb = () => { + flushToken() + if (verb !== null) { + actions.push({ verb, args }) + verb = null + args = [] + } + } + + for (let i = 0; i < text.length; i++) { + const ch = text[i] + if (escape) { + current += ch + escape = false + started = true + continue + } + if (quote) { + if (ch === "\\") { + const next = text[i + 1] + if (next === quote || next === "\\") { + escape = true + started = true + continue + } + current += ch + started = true + continue + } + if (ch === quote) { + // closing quote — do not include it in the token + quote = null + continue + } + current += ch + started = true + continue + } + if (ch === '"' || ch === "'" || ch === "`") { + // opening quote — do not include the quote char in the token + quote = ch + started = true + continue + } + if (ch === ";") { + flushToken() + flushVerb() + continue + } + if (ch === " " || ch === "\t" || ch === "\n" || ch === "\r") { + flushToken() + continue + } + current += ch + started = true + } + if (escape) throw new Error("unterminated escape in script") + if (quote !== null) throw new Error("unterminated quote in script") + flushToken() + flushVerb() + return actions +} diff --git a/packages/kilo-world/src/types.ts b/packages/kilo-world/src/types.ts new file mode 100644 index 00000000000..66a8f85ec66 --- /dev/null +++ b/packages/kilo-world/src/types.ts @@ -0,0 +1,122 @@ +export type Envelope = { + ok: boolean + tool: "browser" | "config" | "computer" + verb: string + session?: string + duration_ms: number + data?: T + warnings: string[] + errors: string[] + reason?: string +} + +export type BrowserCapability = { + headless: boolean + display?: string + chromiumReady: boolean + chromiumVersion?: string + installation: { + state: "available" | "missing" + message?: string + } +} + +export type SessionInfo = { + name: string + createdAt: number + lastUsedAt: number + lastUrl?: string +} + +export type BrowserStatus = { + sessions: SessionInfo[] + capability: BrowserCapability + chromiumPid: number | null +} + +export type TabInfo = { + index: number + url: string + title?: string + active: boolean +} + +export type CookieEntry = { + name: string + value: string + domain: string + path: string + expires?: number + httpOnly?: boolean + secure?: boolean + sameSite?: "Strict" | "Lax" | "None" +} + +export type RefEntry = { + ref: string + role: string + name: string + selector?: string + depth: number +} + +export type Snapshot = { + snapshot: string + refs: RefEntry[] +} + +export type BrowserConfig = { + headless: boolean + antiDetect: boolean + timeoutMs: number + viewport: { width: number; height: number } + executablePath?: string + useSystemChrome?: boolean + args: string[] +} + +export type WorldConfig = { + browser: BrowserConfig + home: string +} + +export type WorldConfigPatch = Omit, "browser"> & { + browser?: Partial +} + +export type DaemonConfig = { + browser?: Pick +} + +export type Action = { + verb: string + args: string[] + directory?: string + paths?: string[] + config?: DaemonConfig +} + +export type RunOptions = { + signal?: AbortSignal + timeoutMs?: number + directory?: string + paths?: string[] + config?: WorldConfig +} + +export type RunResult = { + ok: boolean + durationMs: number + results: ActionResult[] +} + +export type ActionResult = { + ok: boolean + verb: string + args: string[] + data?: unknown + error?: string + durationMs: number + screenshot?: { path: string; bytes: number; mime: string } + refs?: Array<{ ref: string; role: string; name: string; selector?: string }> +} diff --git a/packages/kilo-world/test/build.test.ts b/packages/kilo-world/test/build.test.ts new file mode 100644 index 00000000000..326fc9b2d59 --- /dev/null +++ b/packages/kilo-world/test/build.test.ts @@ -0,0 +1,51 @@ +import { expect, test } from "bun:test" +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { fingerprint } from "../src/daemon/build" + +test("fingerprints relative package roots", async () => { + const root = join(import.meta.dirname, "..") + expect(await fingerprint(".")).toBe(await fingerprint(root)) +}) + +test("invalidates only for relevant dependency lock changes", async () => { + const dir = await mkdtemp(join(tmpdir(), "kilo-world-fingerprint-")) + const root = join(dir, "packages", "kilo-world") + const lock = (version: string, other: string) => `{ + "workspaces": { + "packages/kilo-world": { + "name": "@kilocode/world", + "dependencies": { + "playwright": "catalog:", + }, + }, + "packages/other": { + "name": "other", + }, + }, + "packages": { + "other": ["other@${other}", "", {}, "other-integrity"], + "playwright": ["playwright@${version}", "", { "dependencies": { "playwright-core": "${version}" } }, "playwright-integrity"], + "playwright-core": ["playwright-core@${version}", "", {}, "playwright-core-integrity"], + } +}` + try { + await Promise.all([mkdir(join(root, "script"), { recursive: true }), mkdir(join(root, "src"), { recursive: true })]) + await Promise.all([ + writeFile(join(dir, "package.json"), JSON.stringify({ workspaces: { catalog: { playwright: "1.57.0" } } })), + writeFile(join(root, "package.json"), JSON.stringify({ dependencies: { playwright: "catalog:" } })), + writeFile(join(root, "script", "build-daemon.ts"), ""), + writeFile(join(root, "script", "daemon.ts"), ""), + writeFile(join(root, "src", "index.ts"), ""), + writeFile(join(dir, "bun.lock"), lock("1.57.0", "1.0.0")), + ]) + const first = await fingerprint(root) + await writeFile(join(dir, "bun.lock"), lock("1.57.0", "2.0.0")) + expect(await fingerprint(root)).toBe(first) + await writeFile(join(dir, "bun.lock"), lock("1.58.0", "2.0.0")) + expect(await fingerprint(root)).not.toBe(first) + } finally { + await rm(dir, { recursive: true, force: true }) + } +}) diff --git a/packages/kilo-world/test/daemon.test.ts b/packages/kilo-world/test/daemon.test.ts new file mode 100644 index 00000000000..59d9991bc63 --- /dev/null +++ b/packages/kilo-world/test/daemon.test.ts @@ -0,0 +1,307 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test" +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { chromium } from "playwright" +import { DaemonClient, World } from "../src" +import { DaemonServer } from "../src/daemon/server" +import { WorldDaemon } from "../script/daemon" + +const session = `test-${process.pid}` +const peer = `${session}-peer` +const home = mkdtempSync(join(tmpdir(), "kilo-world-test-")) +const daemon = join(home, WorldDaemon.filename) +const config = World.currentConfig() +const available = existsSync(chromium.executablePath()) +const env = { + daemon: process.env["KILO_WORLD_DAEMON_PATH"], + node: process.env["KILO_WORLD_NODE"], +} + +beforeAll(async () => { + await WorldDaemon.copy(await WorldDaemon.bundle(), home) + process.env["KILO_WORLD_DAEMON_PATH"] = daemon + process.env["KILO_WORLD_NODE"] = Bun.which("node") ?? "node" + World.configure({ home }) +}) + +afterAll(async () => { + await Promise.all([DaemonClient.stop(session), DaemonClient.stop(peer)]) + await Promise.all([ + waitFor(session, () => !DaemonServer.isRunning(session)), + waitFor(peer, () => !DaemonServer.isRunning(peer)), + ]) + if (env.daemon === undefined) delete process.env["KILO_WORLD_DAEMON_PATH"] + else process.env["KILO_WORLD_DAEMON_PATH"] = env.daemon + if (env.node === undefined) delete process.env["KILO_WORLD_NODE"] + else process.env["KILO_WORLD_NODE"] = env.node + World.configure(config) + rmSync(home, { recursive: true, force: true }) +}) + +describe("world daemon", () => { + test("records every packaged daemon output", () => { + const files: unknown = JSON.parse(readFileSync(join(home, WorldDaemon.manifest), "utf8")) + expect(files).toBeArray() + expect(files).toContain(WorldDaemon.filename) + }) + + test("reports invalid daemon and runtime overrides", async () => { + process.env["KILO_WORLD_DAEMON_PATH"] = join(home, "missing.js") + await expect(DaemonClient.ensureRunning(`${session}-missing`)).rejects.toThrow( + "KILO_WORLD_DAEMON_PATH does not exist", + ) + process.env["KILO_WORLD_DAEMON_PATH"] = daemon + + process.env["KILO_WORLD_NODE"] = join(home, "missing-node") + await expect(DaemonClient.ensureRunning(`${session}-runtime`)).rejects.toThrow("KILO_WORLD_NODE does not exist") + process.env["KILO_WORLD_NODE"] = Bun.which("node") ?? "node" + }) + + test("deduplicates concurrent startup and serves authenticated requests", async () => { + await Promise.all([ + DaemonClient.ensureRunning(session, { idleMs: 0 }), + DaemonClient.ensureRunning(session, { idleMs: 0 }), + DaemonClient.ensureRunning(session, { idleMs: 0 }), + ]) + const handshake = DaemonClient.handshake(session) + expect(handshake).not.toBeNull() + expect(DaemonServer.isRunning(session)).toBe(true) + + const denied = await fetch(`${handshake!.url}/call`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ id: "denied", verb: "__status__", args: [], auth: "wrong" }), + }) + expect(denied.status).toBe(401) + + const unsafe = await fetch(`${handshake!.url}/call`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + id: "unsafe", + verb: "status", + args: [], + auth: handshake!.token, + config: { browser: { executablePath: "/tmp/browser" } }, + }), + }) + expect(unsafe.status).toBe(400) + + if (process.platform !== "win32") { + expect(statSync(DaemonServer.pidPath(session)).mode & 0o777).toBe(0o600) + expect(statSync(DaemonServer.handshakePath(session)).mode & 0o777).toBe(0o600) + } + }) + + test("rejects negative idle timeouts", async () => { + await expect(World.setDaemonIdle(session, -1)).rejects.toThrow("cannot be negative") + }) + + test("rejects filesystem paths that were not approved by the caller", async () => { + const file = join(home, "evaluate.js") + writeFileSync(file, "document.title") + const result = await World.runForSession(session, `evaluate --js-file ${JSON.stringify(file)}`) + expect(result.ok).toBe(false) + expect(result.results[0]?.error).toContain("path was not authorized") + }) + + test("reports runtime status without launching Chromium", async () => { + await World.setDaemonIdle(session, 0) + const result = await World.runForSession(session, "daemon.status ; status", { + config: World.currentConfig(), + }) + expect(result.ok).toBe(true) + expect(result.results).toHaveLength(2) + expect(result.results[0]?.data).toMatchObject({ + running: true, + runtime: "node", + idleTimeoutMs: 0, + idleTimeoutRemainingMs: 0, + }) + expect(result.results[1]?.data).toMatchObject({ + capability: expect.not.objectContaining({ chromiumVersion: expect.anything() }), + }) + expect(await World.daemonStatus(session)).toMatchObject({ runtime: "node", runtimeVersion: expect.any(String) }) + }) + + test.skipIf(!available)("isolates browser state and processes between sessions", async () => { + const [first, second] = await Promise.all([ + World.runForSession( + session, + 'navigate --url "data:text/html,First" ; evaluate --js "document.title"', + ), + World.runForSession( + peer, + 'navigate --url "data:text/html,Second" ; evaluate --js "document.title"', + ), + ]) + expect(first.ok).toBe(true) + expect(second.ok).toBe(true) + expect(first.results[1]?.data).toEqual({ result: "First" }) + expect(second.results[1]?.data).toEqual({ result: "Second" }) + expect(DaemonClient.handshake(session)?.pid).not.toBe(DaemonClient.handshake(peer)?.pid) + + const [firstState, secondState] = await Promise.all([ + World.runForSession(session, 'evaluate --js "document.title"'), + World.runForSession(peer, 'evaluate --js "document.title"'), + ]) + expect(firstState.results[0]?.data).toEqual({ result: "First" }) + expect(secondState.results[0]?.data).toEqual({ result: "Second" }) + + const file = join(home, "daemon-browser.png") + const shot = await World.runForSession(session, `screenshot --out ${JSON.stringify(file)} --full --type png`, { + paths: [file], + }) + expect(shot.ok).toBe(true) + expect(shot.results[0]?.screenshot).toMatchObject({ path: file, mime: "image/png" }) + expect(statSync(file).size).toBeGreaterThan(0) + + expect(await DaemonClient.stop(peer)).toBe(true) + await waitFor(peer, () => !DaemonServer.isRunning(peer)) + }) + + test.skipIf(!available)("preserves runner guarantees under Node", async () => { + const dir = join(import.meta.dirname, "..", "node_modules") + mkdirSync(dir, { recursive: true }) + const root = mkdtempSync(join(dir, ".kilo-world-runner-")) + try { + const result = await Bun.build({ + entrypoints: [join(import.meta.dirname, "runner-node.ts")], + target: "node", + format: "esm", + external: ["chromium-bidi", "electron", "playwright"], + }) + expect(result.success).toBe(true) + const output = result.outputs[0] + if (!output) throw new Error("runner Node test bundle produced no output") + const file = join(root, "runner-node.mjs") + await Bun.write(file, output) + const child = Bun.spawn({ + cmd: [process.env["KILO_WORLD_NODE"]!, file], + env: { ...process.env, KILO_WORLD_HOME: join(home, "runner") }, + stdout: "pipe", + stderr: "pipe", + }) + try { + const [code, stdout, stderr] = await Promise.all([ + child.exited, + new Response(child.stdout).text(), + new Response(child.stderr).text(), + ]) + expect(code, `${stdout}\n${stderr}`).toBe(0) + } finally { + if (child.exitCode === null) child.kill() + } + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) + + test.skipIf(!available)("assigns distinct refs to elements with the same accessible name", async () => { + const result = await World.runForSession( + session, + 'navigate --url "data:text/html," ; snapshot ; click --ref e2 ; evaluate --js "document.activeElement.id"', + ) + expect(result.ok).toBe(true) + expect(result.results[1]?.refs?.map((entry) => entry.ref)).toEqual(["e1", "e2"]) + expect(result.results[3]?.data).toEqual({ result: "two" }) + }) + + test.skipIf(!available)("preserves sensitive-looking accessible names in snapshots", async () => { + const result = await World.runForSession( + session, + 'navigate --url "data:text/html," ; snapshot', + ) + expect(result.ok).toBe(true) + expect(result.results[1]?.refs).toEqual([expect.objectContaining({ role: "textbox", name: "Security PIN" })]) + expect(result.results[1]?.data).toEqual( + expect.objectContaining({ snapshot: expect.stringContaining('"Security PIN"') }), + ) + }) + + test.skipIf(!available)("installs anti-detection before documents load", async () => { + const current = World.currentConfig() + try { + const result = await World.runForSession( + session, + 'navigate --url "data:text/html,Detection" ; evaluate --js "navigator.webdriver"', + { config: { ...current, browser: { ...current.browser, antiDetect: true } } }, + ) + expect(result.ok).toBe(true) + expect(result.results[1]?.data).toEqual({ result: null }) + } finally { + await World.runForSession(session, "status", { config: current }) + } + }) + + test.skipIf(!available)("preserves key casing and supports the plus key", async () => { + const result = await World.runForSession(session, 'press-key --chord "A" ; press-key --chord "+"') + expect(result.ok).toBe(true) + expect(result.results.map((item) => item.data)).toEqual([ + { chord: "A", keys: 1 }, + { chord: "+", keys: 1 }, + ]) + }) + + test.skipIf(!available)("tracks the active page while opening and closing tabs", async () => { + const result = await World.runForSession( + session, + 'tabs open --url "data:text/html,Second" ; tabs list ; tabs close ; tabs list', + ) + expect(result.ok).toBe(true) + expect(result.results[1]?.data).toEqual([ + expect.objectContaining({ index: 0, active: false }), + expect.objectContaining({ index: 1, title: "Second", active: true }), + ]) + expect(result.results[3]?.data).toEqual([expect.objectContaining({ index: 0, active: true })]) + }) + + test.skipIf(!available)("opens a blank tab when no URL is provided", async () => { + const result = await World.runForSession(session, "tabs open") + expect(result.ok).toBe(true) + expect(result.results[0]?.data).toEqual(expect.objectContaining({ url: "about:blank" })) + }) + + test.skipIf(!available)("invalidates refs after navigation", async () => { + await World.runForSession(session, 'navigate --url "data:text/html," ; snapshot') + const result = await World.runForSession( + session, + 'navigate --url "data:text/html," ; click --ref e1', + ) + expect(result.ok).toBe(false) + expect(result.results[1]?.error).toContain("run snapshot first") + }) + + test.skipIf(!available)("cancels an action already running in Chromium", async () => { + await World.runForSession(session, 'navigate --url "data:text/html,Cancel"') + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), 100) + const error = await World.runForSession(session, 'evaluate --js "new Promise(() => {})"', { + signal: controller.signal, + }).then( + () => undefined, + (err: unknown) => err, + ) + expect(error).toBeInstanceOf(Error) + expect(error).toHaveProperty("message", expect.stringMatching(/aborted/)) + clearTimeout(timer) + }) + + test("stops cleanly and removes private state files", async () => { + expect(await DaemonClient.stop(session)).toBe(true) + await waitFor(session, () => !DaemonServer.isRunning(session)) + await expect(Bun.file(DaemonServer.pidPath(session)).exists()).resolves.toBe(false) + await expect(Bun.file(DaemonServer.handshakePath(session)).exists()).resolves.toBe(false) + }) +}) + +async function waitFor(id: string, check: () => boolean) { + const start = Date.now() + while (Date.now() - start < 5000) { + if (check()) return + await Bun.sleep(25) + } + const pid = readFileSync(DaemonServer.pidPath(id), "utf8") + throw new Error(`daemon did not stop (pid ${pid})`) +} diff --git a/packages/kilo-world/test/launch.test.ts b/packages/kilo-world/test/launch.test.ts new file mode 100644 index 00000000000..0401346474e --- /dev/null +++ b/packages/kilo-world/test/launch.test.ts @@ -0,0 +1,64 @@ +import { afterAll, describe, expect, test } from "bun:test" +import { Launch } from "../src/core/browser/launch" +import { findSystemChrome } from "../src/core/browser/chrome" +import type { WorldConfig } from "../src/types" + +afterAll(() => {}) + +function cfg(patch: Partial): WorldConfig { + return { + browser: { + headless: true, + antiDetect: false, + timeoutMs: 30_000, + viewport: { width: 1280, height: 720 }, + args: [], + ...patch, + }, + home: "/tmp/world-home", + } +} + +describe("Launch.fromConfig", () => { + test("uses executablePath when explicitly set", () => { + const out = Launch.fromConfig(cfg({ executablePath: "/custom/chrome" })) + expect(out.executablePath).toBe("/custom/chrome") + }) + + test("explicit executablePath wins over useSystemChrome", () => { + const detected = findSystemChrome() + const out = Launch.fromConfig(cfg({ executablePath: "/explicit/chrome", useSystemChrome: true })) + expect(out.executablePath).toBe("/explicit/chrome") + if (detected) expect(out.executablePath).not.toBe(detected) + }) + + test("useSystemChrome resolves to detected chrome when present", () => { + const detected = findSystemChrome() + if (!detected) return + const out = Launch.fromConfig(cfg({ useSystemChrome: true })) + expect(out.executablePath).toBe(detected) + }) + + test("useSystemChrome leaves executablePath unset when chrome is missing", () => { + if (findSystemChrome()) return + const out = Launch.fromConfig(cfg({ useSystemChrome: true })) + expect(out.executablePath).toBeUndefined() + }) + + test("does not set executablePath by default", () => { + const out = Launch.fromConfig(cfg({})) + expect(out.executablePath).toBeUndefined() + }) + + test("hides only headless Chromium launches on Windows", () => { + expect(Launch.hide(true, "win32")).toBe(true) + expect(Launch.hide(false, "win32")).toBe(false) + expect(Launch.hide(true, "linux")).toBe(false) + }) + + test("uses unified headless Chromium without overriding explicit executables", () => { + expect(Launch.channel(Launch.fromConfig(cfg({ headless: true })))).toBe("chromium") + expect(Launch.channel(Launch.fromConfig(cfg({ headless: false })))).toBeUndefined() + expect(Launch.channel(Launch.fromConfig(cfg({ headless: true, executablePath: "/custom/chrome" })))).toBeUndefined() + }) +}) diff --git a/packages/kilo-world/test/parse-script.test.ts b/packages/kilo-world/test/parse-script.test.ts new file mode 100644 index 00000000000..978cf60b622 --- /dev/null +++ b/packages/kilo-world/test/parse-script.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, test } from "bun:test" +import { homedir } from "node:os" +import path from "node:path" +import { World, parseScript, resolvePath } from "../src/index" + +describe("parseScript", () => { + test("expands home-relative paths", () => { + expect(resolvePath("~/screenshots/page.png", "/workspace")).toBe(path.join(homedir(), "screenshots/page.png")) + expect(resolvePath("relative/page.png", "/workspace")).toBe(path.resolve("/workspace/relative/page.png")) + }) + + test("expands environment variables in path syntax from either platform", () => { + const key = "KILO_WORLD_TEST_PATH" + const before = process.env[key] + process.env[key] = path.join(homedir(), "world-output") + try { + const expected = path.join(homedir(), "world-output", "page.png") + expect(resolvePath(`%${key.toLowerCase()}%/page.png`, "/workspace")).toBe(expected) + expect(resolvePath(`$${key}/page.png`, "/workspace")).toBe(expected) + expect(resolvePath(`\${${key}}/page.png`, "/workspace")).toBe(expected) + } finally { + if (before === undefined) delete process.env[key] + else process.env[key] = before + } + }) + + test("preserves undefined environment variables as literal path text", () => { + expect(resolvePath("%KILO_WORLD_UNDEFINED_PATH%/page.png", "/workspace")).toBe( + path.resolve("/workspace/%KILO_WORLD_UNDEFINED_PATH%/page.png"), + ) + }) + + test("rejects agent-controlled session selection", async () => { + const result = await World.run("status --session another-session") + expect(result.ok).toBe(false) + expect(result.results[0]?.error).toBe("--session is reserved for Kilo") + }) + + test("splits on ; and trims", () => { + expect(parseScript("navigate --url a ; click --ref e1 ; screenshot --out /tmp/x.png")).toEqual([ + { verb: "navigate", args: ["--url", "a"] }, + { verb: "click", args: ["--ref", "e1"] }, + { verb: "screenshot", args: ["--out", "/tmp/x.png"] }, + ]) + }) + + test("respects single and double quotes", () => { + expect(parseScript(`navigate --url "https://example.com/path?q=1" ; type --text 'hello world'`)).toEqual([ + { verb: "navigate", args: ["--url", "https://example.com/path?q=1"] }, + { verb: "type", args: ["--text", "hello world"] }, + ]) + }) + + test("skips empty segments", () => { + expect(parseScript(" ;;navigate --url a;; ")).toEqual([{ verb: "navigate", args: ["--url", "a"] }]) + }) + + test("rejects unterminated quote", () => { + expect(() => parseScript(`navigate --url "https://example.com`)).toThrow(/unterminated quote/) + }) + + test("rejects an unterminated quote after a trailing literal backslash", () => { + expect(() => parseScript(`type --text "value\\`)).toThrow(/unterminated quote/) + }) + + test("preserves empty quoted arguments", () => { + expect(parseScript(`fill --ref e1 --value ""`)).toEqual([{ verb: "fill", args: ["--ref", "e1", "--value", ""] }]) + }) + + test("decodes escaped quotes and backslashes", () => { + expect(parseScript(`screenshot --out "C:\\\\screenshots\\\\page.png" ; type --text "say \\"hello\\""`)).toEqual([ + { verb: "screenshot", args: ["--out", "C:\\screenshots\\page.png"] }, + { verb: "type", args: ["--text", 'say "hello"'] }, + ]) + }) + + test("preserves ordinary backslashes in quoted Windows paths", () => { + const script = String.raw`screenshot --out "C:\Users\Aarav\Downloads\page.png"` + expect(parseScript(script)).toEqual([ + { verb: "screenshot", args: ["--out", String.raw`C:\Users\Aarav\Downloads\page.png`] }, + ]) + }) + + test("preserves ; inside single quotes", () => { + const actions = parseScript(`evaluate --js 'var x = 1; return x; done'`) + expect(actions).toEqual([{ verb: "evaluate", args: ["--js", "var x = 1; return x; done"] }]) + }) + + test("preserves ; inside double quotes", () => { + const actions = parseScript(`evaluate --js "var y = 2; return y; done"`) + expect(actions).toEqual([{ verb: "evaluate", args: ["--js", "var y = 2; return y; done"] }]) + }) + + test("preserves ; across multiple verbs when JS is quoted", () => { + const actions = parseScript(`status ; evaluate --js 'a;b;c' ; navigate --url https://example.com`) + expect(actions).toEqual([ + { verb: "status", args: [] }, + { verb: "evaluate", args: ["--js", "a;b;c"] }, + { verb: "navigate", args: ["--url", "https://example.com"] }, + ]) + }) + + test("supports --js-file flag", () => { + const actions = parseScript("evaluate --js-file /tmp/fill.js") + expect(actions).toEqual([{ verb: "evaluate", args: ["--js-file", "/tmp/fill.js"] }]) + }) + + test("supports backtick template literals with semicolons", () => { + const actions = parseScript("evaluate --js `var z = 3; ; done`") + expect(actions).toEqual([{ verb: "evaluate", args: ["--js", "var z = 3; ; done"] }]) + }) +}) + +describe("World.parseScript alias", () => { + test("World.parseScript produces the same output as the canonical parser", () => { + const input = "status ; evaluate --js 'a;b;c' ; navigate --url https://example.com" + expect(World.parseScript(input)).toEqual(parseScript(input)) + }) +}) + +describe("World.configure", () => { + test("returns the default config and accepts patches", () => { + const before = World.currentConfig() + expect(before.browser.timeoutMs).toBe(30_000) + const next = World.configure({ browser: { ...before.browser, headless: false, timeoutMs: 12345 } }) + expect(next.browser.headless).toBe(false) + expect(next.browser.timeoutMs).toBe(12345) + expect(World.currentConfig()).toEqual(next) + World.configure(before) + }) +}) + +describe("World.run", () => { + test("stops after the first failed action", async () => { + const result = await World.run("unknown ; status") + expect(result.ok).toBe(false) + expect(result.results).toHaveLength(1) + expect(result.results[0]?.verb).toBe("unknown") + }) + + test("honors a signal aborted before execution", async () => { + const controller = new AbortController() + controller.abort() + await expect(World.run("status", { signal: controller.signal })).rejects.toThrow() + }) +}) diff --git a/packages/kilo-world/test/runner-node.ts b/packages/kilo-world/test/runner-node.ts new file mode 100644 index 00000000000..3b3e9c7b816 --- /dev/null +++ b/packages/kilo-world/test/runner-node.ts @@ -0,0 +1,37 @@ +import assert from "node:assert/strict" +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { World } from "../src" +import { Runner } from "../src/core/browser/runner" + +const config = World.currentConfig() +const root = mkdtempSync(join(tmpdir(), "kilo-world-runner-")) + +try { + await Runner.configure({ ...config, home: join(root, "state") }) + + const browsers = await Promise.all([Runner.ensureBrowser(), Runner.ensureBrowser(), Runner.ensureBrowser()]) + assert.strictEqual(browsers[1], browsers[0]) + assert.strictEqual(browsers[2], browsers[0]) + + const first = await Runner.ensureBrowser() + await Runner.configure({ browser: { ...config.browser, args: [...config.browser.args, "--disable-notifications"] } }) + const second = await Runner.ensureBrowser() + assert.equal(first.isConnected(), false) + assert.notStrictEqual(second, first) + await Runner.configure({ browser: config.browser }) + + const victim = join(root, "victim") + const marker = join(victim, "marker") + mkdirSync(victim) + writeFileSync(marker, "keep") + await Runner.configure({ ...config, home: join(root, "state") }) + await Runner.attach("../../victim") + await Runner.close("../../victim") + assert.equal(existsSync(marker), true) +} finally { + await Runner.shutdown() + await Runner.configure(config) + rmSync(root, { recursive: true, force: true }) +} diff --git a/packages/kilo-world/tsconfig.json b/packages/kilo-world/tsconfig.json new file mode 100644 index 00000000000..a495f567aaa --- /dev/null +++ b/packages/kilo-world/tsconfig.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "@tsconfig/bun/tsconfig.json", + "compilerOptions": { + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "types": ["bun"], + "noUncheckedIndexedAccess": false + }, + "include": ["src/**/*.ts", "test/**/*.ts"], + "exclude": ["node_modules"] +} From ca6f4680d7f1448780dde72456230df285d7759f Mon Sep 17 00:00:00 2001 From: Aarav Sharma Date: Fri, 31 Jul 2026 20:38:00 -0600 Subject: [PATCH 2/3] feat(cli): add native world browser tool --- .changeset/world-tool.md | 6 + bun.lock | 1 + packages/core/src/v1/config/config.ts | 33 ++ packages/kilo-world/src/client.ts | 2 +- packages/kilo-world/src/config.ts | 14 +- packages/kilo-world/src/daemon/client.ts | 2 + packages/kilo-world/src/daemon/protocol.ts | 8 +- packages/kilo-world/src/index.ts | 4 +- packages/kilo-world/src/types.ts | 5 +- packages/kilo-world/test/daemon.test.ts | 2 +- packages/opencode/package.json | 1 + packages/opencode/script/build.ts | 5 + .../opencode/src/kilocode/plugins/internal.ts | 2 + .../src/kilocode/plugins/world-palette.tsx | 115 ++++++ .../src/kilocode/sandbox/network-tools.ts | 1 + .../opencode/src/kilocode/tool/registry.ts | 48 ++- .../src/kilocode/tool/world-config.ts | 36 ++ .../src/kilocode/tool/world-script.ts | 45 +++ packages/opencode/src/kilocode/tool/world.ts | 336 ++++++++++++++++++ .../test/kilocode/chart-tool-gating.test.ts | 1 + ...l-registry-indexing-import-failure.test.ts | 1 + .../kilocode/tool-registry-indexing.test.ts | 4 + ...l-registry-semantic-import-failure.test.ts | 1 + .../test/kilocode/tool/send-file.test.ts | 1 + .../test/kilocode/world-config.test.ts | 44 +++ .../test/kilocode/world-script.test.ts | 57 +++ packages/sdk/js/src/v2/gen/types.gen.ts | 15 + packages/sdk/openapi.json | 112 ++++++ 28 files changed, 888 insertions(+), 14 deletions(-) create mode 100644 .changeset/world-tool.md create mode 100644 packages/opencode/src/kilocode/plugins/world-palette.tsx create mode 100644 packages/opencode/src/kilocode/tool/world-config.ts create mode 100644 packages/opencode/src/kilocode/tool/world-script.ts create mode 100644 packages/opencode/src/kilocode/tool/world.ts create mode 100644 packages/opencode/test/kilocode/world-config.test.ts create mode 100644 packages/opencode/test/kilocode/world-script.test.ts diff --git a/.changeset/world-tool.md b/.changeset/world-tool.md new file mode 100644 index 00000000000..c7a439ac01e --- /dev/null +++ b/.changeset/world-tool.md @@ -0,0 +1,6 @@ +--- +"@kilocode/cli": minor +"@kilocode/world": minor +--- + +Add the built-in `world` tool for browser automation, letting Kilo navigate sites, click, type, and capture screenshots from the CLI. It runs in a persistent, isolated browser session with per-action permissions, CLI controls for headed browsing and system Chrome, and the required daemon bundled alongside the CLI. diff --git a/bun.lock b/bun.lock index bcb86ee4d73..2200ccf2593 100644 --- a/bun.lock +++ b/bun.lock @@ -552,6 +552,7 @@ "@kilocode/plugin-atomic-chat": "workspace:*", "@kilocode/sandbox": "workspace:*", "@kilocode/sdk": "workspace:*", + "@kilocode/world": "workspace:*", "@lydell/node-pty": "catalog:", "@modelcontextprotocol/sdk": "1.29.0", "@morphllm/morphsdk": "0.2.166", diff --git a/packages/core/src/v1/config/config.ts b/packages/core/src/v1/config/config.ts index 77f5d55160d..d63b340226c 100644 --- a/packages/core/src/v1/config/config.ts +++ b/packages/core/src/v1/config/config.ts @@ -252,6 +252,35 @@ export const Info = Schema.Struct({ description: "Thresholds for truncating tool output. When output exceeds either limit, the full text is written to the truncation directory and a preview is returned.", }), + // kilocode_change start + world: Schema.optional( + Schema.Struct({ + browser: Schema.optional( + Schema.Struct({ + headless: Schema.optional(Schema.Boolean), + anti_detect: Schema.optional(Schema.Boolean), + timeout_ms: Schema.optional(Schema.Number), + viewport: Schema.optional( + Schema.Struct({ + width: Schema.Number, + height: Schema.Number, + }), + ), + executable_path: Schema.optional(Schema.String).annotate({ + description: "Browser executable path. The world tool honors this setting only from global config.", + }), + use_system_chrome: Schema.optional(Schema.Boolean).annotate({ + description: + "Use the system-installed Google Chrome instead of the bundled Chromium. Falls back to bundled Chromium when Chrome is not found.", + }), + args: Schema.optional(Schema.mutable(Schema.Array(Schema.String))).annotate({ + description: "Additional browser arguments. The world tool honors this setting only from global config.", + }), + }), + ), + }), + ).annotate({ description: "Browser runtime settings for the world tool" }), + // kilocode_change end compaction: Schema.optional( Schema.Struct({ auto: Schema.optional(Schema.Boolean).annotate({ @@ -300,6 +329,10 @@ export const Info = Schema.Struct({ openTelemetry: Schema.Boolean.pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed(true))).annotate({ description: "Enable telemetry. Set to false to opt-out.", }), + world_browser: Schema.optional(Schema.Boolean).annotate({ + description: + "Enable the World browser tool. The browser runs headless by default and returns an inline image after every visual action. Disable to hide browser capabilities from the agent.", + }), // kilocode_change end primary_tools: Schema.optional(Schema.mutable(Schema.Array(Schema.String))).annotate({ description: "Tools that should only be available to primary agents.", diff --git a/packages/kilo-world/src/client.ts b/packages/kilo-world/src/client.ts index 8e9a83038da..4dccad9d6b5 100644 --- a/packages/kilo-world/src/client.ts +++ b/packages/kilo-world/src/client.ts @@ -1,4 +1,4 @@ -export { defaultConfig, ensureHome, getConfig, setConfig } from "./config" +export { defaultConfig, ensureHome, getConfig, hasDisplay, setConfig } from "./config" export { DaemonClient } from "./daemon/client" export { parseScript } from "./script-parser" export { resolvePath } from "./path" diff --git a/packages/kilo-world/src/config.ts b/packages/kilo-world/src/config.ts index 0a6b06fd4ef..e972313db1b 100644 --- a/packages/kilo-world/src/config.ts +++ b/packages/kilo-world/src/config.ts @@ -7,12 +7,16 @@ const DEFAULT_HOME = join(process.env["XDG_STATE_HOME"] ?? join(homedir(), ".loc let cached: WorldConfig | null = null +export function hasDisplay(): boolean { + return ( + process.platform === "darwin" || + process.platform === "win32" || + Boolean(process.env["DISPLAY"] || process.env["WAYLAND_DISPLAY"]) + ) +} + export function defaultConfig(): WorldConfig { - const display = process.env["DISPLAY"] - const wayland = process.env["WAYLAND_DISPLAY"] - const desktop = process.platform === "darwin" || process.platform === "win32" - const displayServer = desktop || Boolean(display || wayland) - const headless = !displayServer || process.env["KILO_WORLD_HEADED"] !== "1" + const headless = !hasDisplay() || process.env["KILO_WORLD_HEADED"] !== "1" return { browser: { headless, diff --git a/packages/kilo-world/src/daemon/client.ts b/packages/kilo-world/src/daemon/client.ts index ba281e7eca0..0a1f8161aaa 100644 --- a/packages/kilo-world/src/daemon/client.ts +++ b/packages/kilo-world/src/daemon/client.ts @@ -482,7 +482,9 @@ function daemonConfig(cfg: WorldConfig): DaemonConfig { antiDetect: cfg.browser.antiDetect, timeoutMs: cfg.browser.timeoutMs, viewport: cfg.browser.viewport, + executablePath: cfg.browser.executablePath, useSystemChrome: cfg.browser.useSystemChrome ?? false, + args: [...cfg.browser.args], }, } } diff --git a/packages/kilo-world/src/daemon/protocol.ts b/packages/kilo-world/src/daemon/protocol.ts index 12e8c6ec223..843ee080316 100644 --- a/packages/kilo-world/src/daemon/protocol.ts +++ b/packages/kilo-world/src/daemon/protocol.ts @@ -86,11 +86,17 @@ function config(value: unknown): value is DaemonConfig { if (Object.keys(value).some((key) => key !== "browser")) return false if (value.browser === undefined) return true if (!record(value.browser)) return false - const keys = ["headless", "antiDetect", "timeoutMs", "viewport", "useSystemChrome"] + const keys = ["headless", "antiDetect", "timeoutMs", "viewport", "executablePath", "useSystemChrome", "args"] if (Object.keys(value.browser).some((key) => !keys.includes(key))) return false if (value.browser.headless !== undefined && typeof value.browser.headless !== "boolean") return false if (value.browser.antiDetect !== undefined && typeof value.browser.antiDetect !== "boolean") return false if (value.browser.useSystemChrome !== undefined && typeof value.browser.useSystemChrome !== "boolean") return false + if (value.browser.executablePath !== undefined && typeof value.browser.executablePath !== "string") return false + if ( + value.browser.args !== undefined && + (!Array.isArray(value.browser.args) || !value.browser.args.every((item) => typeof item === "string")) + ) + return false if (value.browser.timeoutMs !== undefined && typeof value.browser.timeoutMs !== "number") return false if (value.browser.viewport === undefined) return true if (!record(value.browser.viewport)) return false diff --git a/packages/kilo-world/src/index.ts b/packages/kilo-world/src/index.ts index 61b240c3516..77c1dc6f98c 100644 --- a/packages/kilo-world/src/index.ts +++ b/packages/kilo-world/src/index.ts @@ -1,10 +1,10 @@ -import { defaultConfig, ensureHome, getConfig, setConfig } from "./config" +import { defaultConfig, ensureHome, getConfig, hasDisplay, setConfig } from "./config" import { DaemonClient } from "./daemon/client" import { dispatch } from "./daemon/dispatch" import { parseScript as parseScriptFn } from "./script-parser" import type { Action, RunOptions, RunResult, WorldConfig, WorldConfigPatch } from "./types" -export { defaultConfig, ensureHome, getConfig, setConfig } +export { defaultConfig, ensureHome, getConfig, hasDisplay, setConfig } export { DaemonClient } export { parseScript } from "./script-parser" export { resolvePath } from "./path" diff --git a/packages/kilo-world/src/types.ts b/packages/kilo-world/src/types.ts index 66a8f85ec66..c327566537b 100644 --- a/packages/kilo-world/src/types.ts +++ b/packages/kilo-world/src/types.ts @@ -85,7 +85,10 @@ export type WorldConfigPatch = Omit, "browser"> & { } export type DaemonConfig = { - browser?: Pick + browser?: Pick< + BrowserConfig, + "headless" | "antiDetect" | "timeoutMs" | "viewport" | "executablePath" | "useSystemChrome" | "args" + > } export type Action = { diff --git a/packages/kilo-world/test/daemon.test.ts b/packages/kilo-world/test/daemon.test.ts index 59d9991bc63..f15e00bec76 100644 --- a/packages/kilo-world/test/daemon.test.ts +++ b/packages/kilo-world/test/daemon.test.ts @@ -83,7 +83,7 @@ describe("world daemon", () => { verb: "status", args: [], auth: handshake!.token, - config: { browser: { executablePath: "/tmp/browser" } }, + config: { browser: { unknown: true } }, }), }) expect(unsafe.status).toBe(400) diff --git a/packages/opencode/package.json b/packages/opencode/package.json index ea7b37fb09d..f27dd1b6687 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -104,6 +104,7 @@ "@kilocode/plugin-atomic-chat": "workspace:*", "@kilocode/sandbox": "workspace:*", "@kilocode/sdk": "workspace:*", + "@kilocode/world": "workspace:*", "@lydell/node-pty": "catalog:", "@modelcontextprotocol/sdk": "1.29.0", "@morphllm/morphsdk": "0.2.166", diff --git a/packages/opencode/script/build.ts b/packages/opencode/script/build.ts index 38e43fdcc98..06dc32eebc9 100755 --- a/packages/opencode/script/build.ts +++ b/packages/opencode/script/build.ts @@ -24,6 +24,7 @@ import { stageBubblewrap } from "./kilocode/bubblewrap" import { LanceDBRuntime } from "../src/kilocode/lancedb" import { KiloSandboxWorker } from "./kilocode/kilo-sandbox-worker" import { KiloSandboxNetwork } from "./kilocode/kilo-sandbox-network" +import { WorldDaemon } from "../../kilo-world/script/daemon" // kilocode_change end const singleFlag = process.argv.includes("--single") @@ -222,6 +223,7 @@ await $`rm -rf dist` const kiloConsoleDist = await buildKiloConsole() const kiloSandboxWorker = await KiloSandboxWorker.bundle() const kiloSandboxNetwork = await KiloSandboxNetwork.bundle() +const worldDaemon = await WorldDaemon.bundle() // kilocode_change end const binaries: Record = {} @@ -326,6 +328,7 @@ for (const item of targets) { await copyTreeSitterWasms(path.resolve(dir, `dist/${name}/bin`)) await copyKiloConsole(kiloConsoleDist, path.resolve(dir, `dist/${name}/bin`)) await KiloSandboxWorker.copy(kiloSandboxWorker, path.resolve(dir, `dist/${name}/bin`)) + await WorldDaemon.copy(worldDaemon, path.resolve(dir, `dist/${name}/bin`)) if (item.os === "linux") { await KiloSandboxNetwork.copy(kiloSandboxNetwork, path.resolve(dir, `dist/${name}/bin`), item.arch) } @@ -363,6 +366,8 @@ for (const item of targets) { console.log("Models snapshot smoke test passed") await KiloSandboxWorker.smoke(binaryPath) console.log("Kilo sandbox mutation worker smoke test passed") + await WorldDaemon.smoke(path.resolve(dir, `dist/${name}/bin/${WorldDaemon.filename}`)) + console.log("Kilo world Node daemon smoke test passed") // kilocode_change end // kilocode_change start } catch (e) { diff --git a/packages/opencode/src/kilocode/plugins/internal.ts b/packages/opencode/src/kilocode/plugins/internal.ts index 9d788771abd..41d3e7b08cb 100644 --- a/packages/opencode/src/kilocode/plugins/internal.ts +++ b/packages/opencode/src/kilocode/plugins/internal.ts @@ -16,6 +16,7 @@ import Remote from "@/kilocode/plugins/remote" import Reload from "@/kilocode/plugins/reload" import SessionSwitcher from "@/kilocode/plugins/session-switcher" import SessionV2Debug from "@/kilocode/plugins/session-v2-debug" +import WorldPalette from "@/kilocode/plugins/world-palette" import type { RuntimeFlags } from "@/effect/runtime-flags" const plugins = [ @@ -34,6 +35,7 @@ const plugins = [ Sandbox, Remote, Reload, + WorldPalette, ] satisfies BuiltinTuiPlugin[] export function withKiloTuiPlugins( diff --git a/packages/opencode/src/kilocode/plugins/world-palette.tsx b/packages/opencode/src/kilocode/plugins/world-palette.tsx new file mode 100644 index 00000000000..b8303ae4b0b --- /dev/null +++ b/packages/opencode/src/kilocode/plugins/world-palette.tsx @@ -0,0 +1,115 @@ +import type { Config } from "@kilocode/sdk/v2" +import type { TuiPlugin } from "@kilocode/plugin/tui" +import { defaultConfig, hasDisplay } from "@kilocode/world/client" +import type { InternalTuiPlugin } from "@/plugin/tui/internal" +import { errorMessage } from "@/util/error" + +const id = "internal:kilo-world-palette" + +type Option = { + name: string + slash: string + fallback: boolean + read: (cfg: Config) => boolean | undefined + patch: (value: boolean) => Config + title: string + toast: (value: boolean) => string + error: string + hidden?: boolean + check?: (value: boolean) => string | undefined +} + +const tui: TuiPlugin = async (api) => { + const command = (opt: Option) => ({ + namespace: "palette", + name: opt.name, + slashName: opt.slash, + category: "System", + get hidden() { + return opt.hidden && api.state.config.experimental?.world_browser === false + }, + title: opt.title, + async run() { + try { + const response = await api.client.global.config.get({}) + if (response.error) { + api.ui.toast({ + message: `${opt.error}: ${errorMessage(response.error)}`, + variant: "error", + duration: 5000, + }) + return + } + const current = opt.read(response.data ?? {}) ?? opt.fallback + const next = !current + const issue = opt.check?.(next) + if (issue) { + api.ui.toast({ message: issue, variant: "error", duration: 5000 }) + return + } + const result = await api.client.global.config.update({ config: opt.patch(next) }) + if (result.error) { + api.ui.toast({ + message: `${opt.error}: ${errorMessage(result.error)}`, + variant: "error", + duration: 5000, + }) + return + } + api.ui.toast({ message: opt.toast(next), variant: "success" }) + api.ui.dialog.clear() + } catch (err) { + api.ui.toast({ message: `${opt.error}: ${errorMessage(err)}`, variant: "error", duration: 5000 }) + } + }, + }) + + api.keymap.registerLayer({ + commands: [ + command({ + name: "world.toggle", + slash: "world", + fallback: true, + read: (cfg) => cfg.experimental?.world_browser, + patch: (value) => ({ experimental: { world_browser: value } }), + title: "Toggle Global World (Browser Use)", + toast: (value) => `Global World preference set to ${value ? "enabled" : "disabled"}`, + error: "Failed to toggle World", + }), + command({ + name: "world.headless", + slash: "world-headless", + fallback: defaultConfig().browser.headless, + read: (cfg) => cfg.world?.browser?.headless, + patch: (value) => ({ world: { browser: { headless: value } } }), + title: "Toggle Global World Headless Mode", + toast: (value) => + `Global World browser preference set to ${value ? "headless" : "a visible window"}; project configuration takes precedence when present`, + error: "Failed to toggle World browser mode", + hidden: true, + check: (value) => + !value && !hasDisplay() ? "Cannot show the World browser because no display server is available" : undefined, + }), + command({ + name: "world.chrome", + slash: "world-chrome", + fallback: false, + read: (cfg) => cfg.world?.browser?.use_system_chrome, + patch: (value) => ({ world: { browser: { use_system_chrome: value } } }), + title: "Toggle Global World Browser Source", + toast: (value) => + `Global World browser preference set to ${value ? "prefer system Chrome when installed" : "bundled Chromium"}; project configuration takes precedence when present`, + error: "Failed to change World browser", + hidden: true, + }), + ], + bindings: [], + }) +} + +const plugin: InternalTuiPlugin = { + id, + tui, +} + +export default plugin diff --git a/packages/opencode/src/kilocode/sandbox/network-tools.ts b/packages/opencode/src/kilocode/sandbox/network-tools.ts index ad2caa9dda4..7bde68bbd6a 100644 --- a/packages/opencode/src/kilocode/sandbox/network-tools.ts +++ b/packages/opencode/src/kilocode/sandbox/network-tools.ts @@ -16,4 +16,5 @@ export const host = [ { id: "interactive_terminal", file: "kilocode/tool/interactive-terminal.ts" }, { id: "notebook_execute", file: "kilocode/tool/notebook-host.ts" }, { id: "background_process", file: "kilocode/tool/background-process.ts" }, + { id: "world", file: "kilocode/tool/world.ts" }, ] as const diff --git a/packages/opencode/src/kilocode/tool/registry.ts b/packages/opencode/src/kilocode/tool/registry.ts index bb86481a6d1..8efd0c011c2 100644 --- a/packages/opencode/src/kilocode/tool/registry.ts +++ b/packages/opencode/src/kilocode/tool/registry.ts @@ -11,6 +11,7 @@ import { MemoryRecallTool } from "./memory-recall" import { MemorySaveTool } from "./memory-save" import { NotifyUserTool } from "./notify-user" import { SendFileTool } from "./send-file" +import { WorldTool } from "./world" import * as Tool from "../../tool/tool" import { Flag } from "@opencode-ai/core/flag/flag" import { Effect } from "effect" @@ -84,14 +85,44 @@ export namespace KiloToolRegistry { const sessions = yield* KiloSessions.Service const notify = yield* NotifyUserTool.pipe(Effect.provideService(KiloSessions.Service, sessions)) const send = yield* SendFileTool + const world = yield* WorldTool if (!notebook) - return { codebase, recall, managerModels, memory, save, manager, process, chart, image, terminal, notify, send } + return { + codebase, + recall, + managerModels, + memory, + save, + manager, + process, + chart, + image, + terminal, + notify, + send, + world, + } const tools = yield* Effect.all({ notebookRead: NotebookReadTool, notebookEdit: NotebookEditTool, notebookExecute: NotebookExecuteTool, }).pipe(Effect.provideService(Notebook.Service, notebook)) - return { codebase, recall, managerModels, memory, save, manager, process, chart, image, terminal, notify, send, ...tools } + return { + codebase, + recall, + managerModels, + memory, + save, + manager, + process, + chart, + image, + terminal, + notify, + send, + world, + ...tools, + } }) } @@ -111,6 +142,7 @@ export namespace KiloToolRegistry { terminal?: Tool.Info notify: Tool.Info send: Tool.Info + world: Tool.Info notebookRead?: Tool.Info notebookEdit?: Tool.Info notebookExecute?: Tool.Info @@ -131,6 +163,7 @@ export namespace KiloToolRegistry { image: Tool.init(tools.image), notify: Tool.init(tools.notify), send: Tool.init(tools.send), + world: Tool.init(tools.world), }) const terminal = tools.terminal ? yield* Tool.init(tools.terminal) : undefined const notebooks = @@ -207,11 +240,19 @@ export namespace KiloToolRegistry { terminal?: Tool.Def notify: Tool.Def send: Tool.Def + world: Tool.Def notebookRead?: Tool.Def notebookEdit?: Tool.Def notebookExecute?: Tool.Def }, - cfg: { experimental?: { codebase_search?: boolean; image_generation?: boolean; native_notebook_tools?: boolean } }, + cfg: { + experimental?: { + codebase_search?: boolean + image_generation?: boolean + native_notebook_tools?: boolean + world_browser?: boolean + } + }, ): Tool.Def[] { return [ ...(cfg.experimental?.codebase_search === true ? [tools.codebase] : []), @@ -223,6 +264,7 @@ export namespace KiloToolRegistry { ...(Flag.KILO_CLIENT === "vscode" ? [tools.chart] : []), ...(Flag.KILO_CLIENT === "cli" || Flag.KILO_CLIENT === "vscode" ? [tools.process] : []), ...(Flag.KILO_CLIENT === "cli" && tools.terminal ? [tools.terminal] : []), + ...(Flag.KILO_CLIENT === "cli" && cfg.experimental?.world_browser !== false ? [tools.world] : []), ...(Flag.KILO_CLIENT === "vscode" ? [tools.managerModels, tools.manager] : []), ...(Flag.KILO_CLIENT === "vscode" && cfg.experimental?.native_notebook_tools === true && diff --git a/packages/opencode/src/kilocode/tool/world-config.ts b/packages/opencode/src/kilocode/tool/world-config.ts new file mode 100644 index 00000000000..e08ebe34f48 --- /dev/null +++ b/packages/opencode/src/kilocode/tool/world-config.ts @@ -0,0 +1,36 @@ +import { defaultConfig } from "@kilocode/world/client" +import type { WorldConfig } from "@kilocode/world/types" + +export type Input = { + world?: { + browser?: { + headless?: boolean + anti_detect?: boolean + timeout_ms?: number + viewport?: { width: number; height: number } + executable_path?: string + use_system_chrome?: boolean + args?: string[] + } + } +} + +export function resolve(cfg: Input, trusted: Input = {}): WorldConfig { + const base = defaultConfig() + const browser = cfg.world?.browser + const global = trusted.world?.browser + if (!browser && !global) return base + return { + browser: { + ...base.browser, + ...(browser?.headless !== undefined ? { headless: browser.headless } : {}), + ...(browser?.anti_detect !== undefined ? { antiDetect: browser.anti_detect } : {}), + ...(browser?.timeout_ms !== undefined ? { timeoutMs: browser.timeout_ms } : {}), + ...(browser?.viewport ? { viewport: browser.viewport } : {}), + ...(global?.args ? { args: [...global.args] } : {}), + ...(global?.executable_path ? { executablePath: global.executable_path } : {}), + ...(browser?.use_system_chrome !== undefined ? { useSystemChrome: browser.use_system_chrome } : {}), + }, + home: base.home, + } +} diff --git a/packages/opencode/src/kilocode/tool/world-script.ts b/packages/opencode/src/kilocode/tool/world-script.ts new file mode 100644 index 00000000000..976f2b39142 --- /dev/null +++ b/packages/opencode/src/kilocode/tool/world-script.ts @@ -0,0 +1,45 @@ +import { parseScript, resolvePath } from "@kilocode/world/client" +import type { Action } from "@kilocode/world/types" + +export type WorldScript = { + actions: Action[] + urls: string[] + reads: string[] + writes: string[] + evaluates: boolean +} + +export function inspect(script: string, dir: string): WorldScript { + const actions = parseScript(script) + const urls: string[] = [] + const reads: string[] = [] + const writes: string[] = [] + const evaluates = actions.some((action) => action.verb === "evaluate") + for (const action of actions) { + if (flag(action, "--session") !== undefined) throw new Error("--session is reserved for Kilo") + const url = flag(action, "--url") + if (url && (action.verb === "navigate" || (action.verb === "tabs" && action.args[0] === "open"))) { + urls.push(url) + } + const input = action.verb === "evaluate" ? flag(action, "--js-file") : undefined + if (input) reads.push(resolvePath(input, dir)) + const output = action.verb === "screenshot" ? flag(action, "--out") : undefined + if (output) writes.push(resolvePath(output, dir)) + } + return { + actions, + urls: [...new Set(urls)], + reads: [...new Set(reads)], + writes: [...new Set(writes)], + evaluates, + } +} + +function flag(action: Action, name: string): string | undefined { + for (let i = 0; i < action.args.length; i++) { + const value = action.args[i] + if (value === name) return action.args[i + 1] + if (value?.startsWith(`${name}=`)) return value.slice(name.length + 1) + } + return undefined +} diff --git a/packages/opencode/src/kilocode/tool/world.ts b/packages/opencode/src/kilocode/tool/world.ts new file mode 100644 index 00000000000..fd9764e641a --- /dev/null +++ b/packages/opencode/src/kilocode/tool/world.ts @@ -0,0 +1,336 @@ +// kilocode_change - new file +import { Effect, Schema } from "effect" +import * as path from "path" +import { pathToFileURL } from "node:url" +import { Tool } from "../../tool/tool" +import { Config } from "@/config/config" +import { InstanceState } from "@/effect/instance-state" +import { assertExternalDirectoryEffect } from "../../tool/external-directory" +import { DaemonClient } from "@kilocode/world/client" +import type { ActionResult, RunResult } from "@kilocode/world/types" +import * as Log from "@opencode-ai/core/util/log" +import { inspect } from "./world-script" +import type { WorldConfig } from "@kilocode/world/types" +import { resolve } from "./world-config" + +const log = Log.create({ service: "kilocode-tool-world" }) +const MAX_TIMEOUT = 10 * 60_000 +const MAX_INLINE_BYTES = 1_000_000 +const MAX_SCREENSHOTS = 20 +const MAX_FILES = 200 +const MAX_AGE = 7 * 24 * 60 * 60_000 + +const DESCRIPTION = `Drive a Chromium browser to render pages, run JavaScript, click through UI, and capture screenshots. State persists across calls in a per-session daemon. + +Proactively use this to verify UI changes (component, layout, style) without being asked by navigating to the page and confirming the result. + +Scripts are ;-separated verb calls. Prefer CSS selectors (\`#foo\`, \`input#bar\`) over role selectors — they pierce shadow DOM and survive across snapshots. + +Verb grammar: + Common: \`--ref \` from snapshot (preferred) or \`--selector \`. Values with spaces or \`;\` need quotes. + status - capability, sessions, Chromium installation state + navigate --url [--wait ] [--timeout ] + snapshot - DOM walk with stable [ref=eN] ids and CSS selectors; prefer over screenshot + click | type --text | fill --value [--force] | hover | scroll --dx N --dy N - each takes --ref or --selector + press-key --chord "" (e.g. "Control+a") + drag --from --to + wait-for --selector | --text | --url [--timeout ] + screenshot --out [--full] [--wait ] [--type png|jpeg] [--quality 50-100] + evaluate --js | --js-file + tabs list | open --url | select --index | close + cookies get --domain D | set --name N --value V --domain D | clear [--domain D] + shutdown - close browser; daemon stays alive + daemon.start --idle | daemon.status | daemon.stop + +Example: + world({ script: "navigate --url http://localhost:3000/settings ; snapshot ; screenshot --out /tmp/check.png" }) +` + +const Params = Schema.Struct({ + script: Schema.String.annotate({ + description: "A ;-separated sequence of browser actions. Example: `navigate --url https://example.com ; snapshot`.", + }), + timeout: Schema.optional(Schema.Number).annotate({ + description: "Overall timeout in milliseconds. Defaults to 60_000 and is capped at 600_000.", + }), +}) +export type Params = Schema.Schema.Type + +type Meta = { + title: string + ok: boolean + durationMs: number + actions: number + failedAt?: number + daemonStarted?: boolean +} + +function record(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} + +function summarize(r: ActionResult): string { + if (!r.ok) return ` → error: ${r.error ?? "unknown"} (${r.durationMs}ms)` + const data = record(r.data) ? r.data : undefined + if (!data) return ` → ok (${r.durationMs}ms)` + if (r.verb === "navigate") { + const u = typeof data["finalUrl"] === "string" ? data["finalUrl"] : "" + return ` → ${u} (${r.durationMs}ms)` + } + if (r.verb === "snapshot") { + return ` → ${renderRefList(r.refs, 8)}` + } + if (r.verb === "screenshot") { + return ` → ${r.screenshot?.bytes ?? 0} bytes (${r.durationMs}ms)` + } + if (r.verb === "evaluate") { + return ` → ${JSON.stringify(data["result"])} (${r.durationMs}ms)` + } + return ` → ${JSON.stringify(data).slice(0, 200)} (${r.durationMs}ms)` +} + +function title(result: RunResult, failed?: ActionResult): string { + if (failed) return `world ${failed.verb} failed` + if (result.results.length === 1) return `world ${result.results[0].verb}` + return `world ${result.results.length} actions` +} + +function renderRefList( + refs: Array<{ ref: string; role: string; name: string; selector?: string }> | undefined, + limit: number, +): string { + if (!refs || refs.length === 0) return "0 refs" + const lines: string[] = [`${refs.length} refs:`] + for (const r of refs.slice(0, limit)) { + const name = (r.name ?? "").slice(0, 60).replace(/\s+/g, " ").trim() + const sel = r.selector ?? "" + lines.push(` ${r.ref} [${r.role}] sel=${sel}${name ? ` name=${JSON.stringify(name)}` : ""}`) + } + if (refs.length > limit) lines.push(` … (${refs.length - limit} more)`) + return lines.join("\n") +} + +async function attachmentForResult( + r: ActionResult, + directory: string, +): Promise<{ type: "file"; mime: string; url: string; filename: string } | undefined> { + if (!r.screenshot) return undefined + const abs = path.isAbsolute(r.screenshot.path) ? r.screenshot.path : path.join(directory, r.screenshot.path) + const mime = r.screenshot.mime ?? "image/png" + const data = await readInlineData(abs, mime) + if (data) return { type: "file", mime, url: data, filename: path.basename(abs) } + return { type: "file", mime, url: pathToFileURL(abs).href, filename: path.basename(abs) } +} + +async function formatResult( + result: RunResult, + directory: string, +): Promise<{ + output: string + attachments: Array<{ type: "file"; mime: string; url: string; filename: string }> +}> { + const lines: string[] = [] + for (const [i, r] of result.results.entries()) { + const summary = summarize(r) + lines.push(`[${i + 1}/${result.results.length}] ${r.verb}${summary.includes("\n") ? "\n" + summary : summary}`) + } + const shot = result.results.findLast((item) => item.screenshot) + const att = shot ? await attachmentForResult(shot, directory) : undefined + const attachments = att ? [att] : [] + return { output: lines.join("\n"), attachments } +} + +async function readInlineData(abs: string, mime: string = "image/png"): Promise { + try { + const file = Bun.file(abs) + if (!(await file.exists()) || file.size > MAX_INLINE_BYTES) return null + const buf = await file.bytes() + return `data:${mime};base64,${Buffer.from(buf).toString("base64")}` + } catch { + return null + } +} + +async function prune(dir: string, prefix: string): Promise { + const files: Array<{ path: string; modified: number }> = [] + try { + for await (const file of new Bun.Glob("*.jpg").scan({ cwd: dir, absolute: true })) { + files.push({ path: file, modified: Bun.file(file).lastModified }) + } + } catch (err) { + if (err instanceof Error && "code" in err && err.code === "ENOENT") return + throw err + } + files.sort((a, b) => b.modified - a.modified) + const now = Date.now() + const session = files.filter((item) => path.basename(item.path).startsWith(prefix)) + const remove = new Set([ + ...files.filter((item) => now - item.modified > MAX_AGE).map((item) => item.path), + ...files.slice(MAX_FILES - 1).map((item) => item.path), + ...session.slice(MAX_SCREENSHOTS - 1).map((item) => item.path), + ]) + await Promise.all( + [...remove].map((file) => + Bun.file(file) + .delete() + .catch((err) => { + if (err instanceof Error && "code" in err && err.code === "ENOENT") return + throw err + }), + ), + ) +} + +function screenshotPath(config: WorldConfig, session: string, call: string | undefined): string { + const name = `${prefix(session)}${call ?? Date.now()}`.replace(/[^a-zA-Z0-9_.-]/g, "_") + return path.join(config.home, "screenshots", `${name}.jpg`) +} + +function prefix(session: string): string { + return `${session.replace(/[^a-zA-Z0-9_.-]/g, "_")}-` +} + +export const WorldTool = Tool.define( + "world", + Effect.gen(function* () { + const configs = yield* Config.Service + return { + description: DESCRIPTION, + parameters: Params, + execute: (params: Params, ctx: Tool.Context) => + Effect.gen(function* () { + const inst = yield* InstanceState.context + const config = resolve(yield* configs.get(), yield* configs.getGlobal()) + const script = inspect(params.script, inst.directory) + + yield* ctx.ask({ + permission: "world", + patterns: ["browser"], + always: ["browser"], + metadata: { script: params.script }, + }) + if (script.evaluates) { + yield* ctx.ask({ + permission: "world", + patterns: ["evaluate"], + always: ["evaluate"], + metadata: { script: params.script }, + }) + } + for (const url of script.urls) { + if (!url.startsWith("http://") && !url.startsWith("https://")) { + throw new Error(`URL must start with http:// or https://: ${url}`) + } + yield* ctx.ask({ + permission: "webfetch", + patterns: [url], + always: ["*"], + metadata: { script: params.script }, + }) + } + for (const file of script.reads) { + yield* assertExternalDirectoryEffect(ctx, file) + yield* ctx.ask({ + permission: "read", + patterns: [path.relative(inst.worktree, file)], + always: ["*"], + metadata: { filepath: file }, + }) + } + for (const file of script.writes) { + yield* assertExternalDirectoryEffect(ctx, file) + yield* ctx.ask({ + permission: "edit", + patterns: [path.relative(inst.worktree, file)], + always: ["*"], + metadata: { filepath: file }, + }) + } + + const wasRunning = DaemonClient.isRunning(ctx.sessionID) + const requested = params.timeout ?? 60_000 + if (!Number.isFinite(requested) || requested <= 0) throw new Error("timeout must be a positive finite number") + const timeout = Math.min(requested, MAX_TIMEOUT) + const controller = new AbortController() + const onAbort = () => controller.abort() + ctx.abort.addEventListener("abort", onAbort, { once: true }) + if (ctx.abort.aborted) controller.abort() + const timer = setTimeout(() => controller.abort(), timeout) + const run = yield* Effect.promise(() => + DaemonClient.runViaSession(ctx.sessionID, params.script, { + signal: controller.signal, + timeoutMs: timeout, + directory: inst.directory, + paths: [...script.reads, ...script.writes], + config, + }).finally(() => { + clearTimeout(timer) + ctx.abort.removeEventListener("abort", onAbort) + }), + ) + + const failedIdx = run.results.findIndex((r) => !r.ok) + const failed = failedIdx >= 0 ? run.results[failedIdx] : undefined + const name = title(run, failed) + const meta: Meta = { + title: name, + ok: run.ok, + durationMs: run.durationMs, + actions: run.results.length, + daemonStarted: !wasRunning, + ...(failedIdx >= 0 ? { failedAt: failedIdx } : {}), + } + + const formatted = yield* Effect.promise(() => formatResult(run, inst.directory)) + + const lastResult = run.results[run.results.length - 1] + const visual = + lastResult && + !["status", "close", "shutdown", "daemon.start", "daemon.status", "daemon.stop"].includes(lastResult.verb) + if (run.ok && visual && !lastResult.screenshot) { + const out = screenshotPath(config, ctx.sessionID, ctx.callID) + const auto = yield* Effect.promise(async () => { + await prune(path.dirname(out), prefix(ctx.sessionID)) + return DaemonClient.runViaSession( + ctx.sessionID, + `screenshot --out ${JSON.stringify(out)} --type jpeg --quality 80`, + { + signal: ctx.abort, + timeoutMs: 15_000, + directory: inst.directory, + paths: [out], + config, + }, + ) + }).pipe( + Effect.catchCause((cause) => + Effect.sync(() => { + log.warn("auto-screenshot failed", { cause: String(cause) }) + return undefined + }), + ), + ) + if (auto) { + const shot = auto.results[0] + if (shot?.screenshot) { + const att = yield* Effect.promise(() => attachmentForResult(shot, inst.directory)) + if (att) { + formatted.attachments.splice(0, formatted.attachments.length, att) + formatted.output += `\n[auto-screenshot] ${shot.screenshot.bytes} bytes` + } + } + } + } + + return { + title: name, + output: run.ok + ? formatted.output + : `${formatted.output}\n\nScript failed at action ${failedIdx + 1}: ${failed?.error ?? "unknown"}`, + metadata: meta, + ...(formatted.attachments.length > 0 ? { attachments: formatted.attachments } : {}), + } + }).pipe(Effect.orDie), + } + }), +) diff --git a/packages/opencode/test/kilocode/chart-tool-gating.test.ts b/packages/opencode/test/kilocode/chart-tool-gating.test.ts index b6eb0862802..8665273607b 100644 --- a/packages/opencode/test/kilocode/chart-tool-gating.test.ts +++ b/packages/opencode/test/kilocode/chart-tool-gating.test.ts @@ -17,6 +17,7 @@ const tools = { image: stub("image"), notify: stub("notify"), send: stub("send_file"), + world: stub("world"), } function ids(client: string) { diff --git a/packages/opencode/test/kilocode/tool-registry-indexing-import-failure.test.ts b/packages/opencode/test/kilocode/tool-registry-indexing-import-failure.test.ts index 900be12330f..517be0b6338 100644 --- a/packages/opencode/test/kilocode/tool-registry-indexing-import-failure.test.ts +++ b/packages/opencode/test/kilocode/tool-registry-indexing-import-failure.test.ts @@ -46,6 +46,7 @@ function infos() { image: info("generate_image"), notify: info("notify_user"), send: info("send_file"), + world: info("world"), notebookRead: info("notebook_read"), notebookEdit: info("notebook_edit"), notebookExecute: info("notebook_execute"), diff --git a/packages/opencode/test/kilocode/tool-registry-indexing.test.ts b/packages/opencode/test/kilocode/tool-registry-indexing.test.ts index db247c7c63a..b80175b601e 100644 --- a/packages/opencode/test/kilocode/tool-registry-indexing.test.ts +++ b/packages/opencode/test/kilocode/tool-registry-indexing.test.ts @@ -344,6 +344,7 @@ describe("kilocode tool registry indexing", () => { terminal: def("interactive_terminal"), notify: def("notify_user"), send: def("send_file"), + world: def("world"), notebookRead: def("notebook_read"), notebookEdit: def("notebook_edit"), notebookExecute: def("notebook_execute"), @@ -358,6 +359,7 @@ describe("kilocode tool registry indexing", () => { "recall", "background_process", "interactive_terminal", + "world", "notify_user", "send_file", ]) @@ -370,6 +372,7 @@ describe("kilocode tool registry indexing", () => { "recall", "background_process", "interactive_terminal", + "world", "notify_user", "send_file", ], @@ -387,6 +390,7 @@ describe("kilocode tool registry indexing", () => { "recall", "background_process", "interactive_terminal", + "world", "notify_user", "send_file", ]) diff --git a/packages/opencode/test/kilocode/tool-registry-semantic-import-failure.test.ts b/packages/opencode/test/kilocode/tool-registry-semantic-import-failure.test.ts index 16bffc77f3a..355e556030a 100644 --- a/packages/opencode/test/kilocode/tool-registry-semantic-import-failure.test.ts +++ b/packages/opencode/test/kilocode/tool-registry-semantic-import-failure.test.ts @@ -58,6 +58,7 @@ function infos() { image: info("generate_image"), notify: info("notify_user"), send: info("send_file"), + world: info("world"), notebookRead: info("notebook_read"), notebookEdit: info("notebook_edit"), notebookExecute: info("notebook_execute"), diff --git a/packages/opencode/test/kilocode/tool/send-file.test.ts b/packages/opencode/test/kilocode/tool/send-file.test.ts index 6afb16046c2..238a3d2a1ae 100644 --- a/packages/opencode/test/kilocode/tool/send-file.test.ts +++ b/packages/opencode/test/kilocode/tool/send-file.test.ts @@ -464,6 +464,7 @@ describe("send_file tool", () => { image: tool, notify: { id: "notify_user" } as Tool.Def, send: tool, + world: tool, }, {}, ) diff --git a/packages/opencode/test/kilocode/world-config.test.ts b/packages/opencode/test/kilocode/world-config.test.ts new file mode 100644 index 00000000000..5ec9c135863 --- /dev/null +++ b/packages/opencode/test/kilocode/world-config.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, test } from "bun:test" +import { defaultConfig } from "@kilocode/world/client" +import { resolve } from "../../src/kilocode/tool/world-config" + +describe("world tool config", () => { + test("does not inherit browser settings from a previous project", () => { + const base = defaultConfig() + const first = resolve({ + world: { + browser: { + headless: !base.browser.headless, + use_system_chrome: true, + args: ["--disable-notifications"], + }, + }, + }) + const second = resolve({}) + + expect(first.browser).toMatchObject({ + headless: !base.browser.headless, + useSystemChrome: true, + args: base.browser.args, + }) + expect(second).toEqual(base) + }) + + test("accepts executable settings only from global config", () => { + const project = { world: { browser: { executable_path: "/tmp/project", args: ["--project"] } } } + const global = { world: { browser: { executable_path: "/tmp/global", args: ["--global"] } } } + + expect(resolve(project).browser).toMatchObject({ args: [] }) + expect(resolve(project, global).browser).toMatchObject({ executablePath: "/tmp/global", args: ["--global"] }) + }) + + test("fills partial project settings from defaults", () => { + const base = defaultConfig() + const config = resolve({ world: { browser: { timeout_ms: 12_345 } } }) + + expect(config.browser).toEqual({ + ...base.browser, + timeoutMs: 12_345, + }) + }) +}) diff --git a/packages/opencode/test/kilocode/world-script.test.ts b/packages/opencode/test/kilocode/world-script.test.ts new file mode 100644 index 00000000000..262abbd4ce7 --- /dev/null +++ b/packages/opencode/test/kilocode/world-script.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, test } from "bun:test" +import { homedir } from "node:os" +import path from "node:path" +import { inspect } from "../../src/kilocode/tool/world-script" + +describe("world script inspection", () => { + test("finds every navigated URL and deduplicates it", () => { + const script = inspect( + "navigate --url=https://example.com ; tabs open --url https://kilo.ai ; navigate --url https://example.com", + "/workspace", + ) + expect(script.urls).toEqual(["https://example.com", "https://kilo.ai"]) + expect(script.evaluates).toBe(false) + }) + + test("resolves file reads and writes against the active directory", () => { + const root = path.resolve("/workspace/project") + const script = inspect( + "evaluate --js-file scripts/check.js ; screenshot --out=artifacts/page.png ; screenshot --out /tmp/final.png", + root, + ) + expect(script.reads).toEqual([path.join(root, "scripts/check.js")]) + expect(script.writes).toEqual([path.join(root, "artifacts/page.png"), path.normalize("/tmp/final.png")]) + }) + + test("expands home-relative paths before requesting permission", () => { + const script = inspect("screenshot --out ~/screenshots/page.png", "/workspace") + expect(script.writes).toEqual([path.join(homedir(), "screenshots/page.png")]) + }) + + test("expands environment paths before requesting permission", () => { + const key = "KILO_WORLD_TEST_OUTPUT" + const before = process.env[key] + process.env[key] = path.join(homedir(), "screenshots") + try { + const script = inspect(`screenshot --out %${key}%/page.png`, "/workspace") + expect(script.writes).toEqual([path.join(homedir(), "screenshots/page.png")]) + } finally { + if (before === undefined) delete process.env[key] + else process.env[key] = before + } + }) + + test("rejects agent-controlled session selection", () => { + expect(() => inspect("status --session another-session", "/workspace")).toThrow("--session is reserved for Kilo") + }) + + test("identifies scripts that execute JavaScript", () => { + expect(inspect('evaluate --js "document.title"', "/workspace").evaluates).toBe(true) + }) + + test("does not treat unrelated --url and --out flags as capabilities", () => { + const script = inspect("wait-for --url https://example.com ; evaluate --out secret.txt --js 1", "/workspace") + expect(script.urls).toEqual([]) + expect(script.writes).toEqual([]) + }) +}) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index f234bbed584..ddc159b5afe 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1723,6 +1723,20 @@ export type Config = { max_lines?: number max_bytes?: number } + world?: { + browser?: { + headless?: boolean + anti_detect?: boolean + timeout_ms?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + viewport?: { + width: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + height: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + } + executable_path?: string + use_system_chrome?: boolean + args?: Array + } + } compaction?: { auto?: boolean /** @@ -1744,6 +1758,7 @@ export type Config = { native_notebook_tools?: boolean speech_to_text_model?: string openTelemetry?: boolean + world_browser?: boolean primary_tools?: Array continue_loop_on_deny?: boolean sandbox?: boolean diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 71ff553fff6..973f587840c 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -29635,6 +29635,114 @@ }, "additionalProperties": false }, + "world": { + "type": "object", + "properties": { + "browser": { + "type": "object", + "properties": { + "headless": { + "type": "boolean" + }, + "anti_detect": { + "type": "boolean" + }, + "timeout_ms": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + }, + { + "type": "string", + "enum": ["Infinity", "-Infinity", "NaN"] + } + ] + }, + "viewport": { + "type": "object", + "properties": { + "width": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + }, + { + "type": "string", + "enum": ["Infinity", "-Infinity", "NaN"] + } + ] + }, + "height": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + }, + { + "type": "string", + "enum": ["Infinity", "-Infinity", "NaN"] + } + ] + } + }, + "required": ["width", "height"], + "additionalProperties": false + }, + "executable_path": { + "type": "string" + }, + "use_system_chrome": { + "type": "boolean", + "description": "Use the system-installed Google Chrome instead of the bundled Chromium. Falls back to bundled Chromium when Chrome is not found." + }, + "args": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false, + "description": "Browser runtime settings for the world tool" + }, "compaction": { "type": "object", "properties": { @@ -29713,6 +29821,10 @@ "openTelemetry": { "type": "boolean" }, + "world_browser": { + "type": "boolean", + "description": "Enable the World browser tool. The browser runs headless by default and returns an inline image after every visual action. Disable to hide browser capabilities from the agent." + }, "primary_tools": { "type": "array", "items": { From 9f6b42d878058614bb980684b96cca71a8b64af6 Mon Sep 17 00:00:00 2001 From: Aarav Sharma Date: Fri, 31 Jul 2026 20:38:19 -0600 Subject: [PATCH 3/3] feat(vscode): integrate world browser automation --- .changeset/world-tool.md | 3 +- packages/kilo-vscode/package.json | 15 -- packages/kilo-vscode/script/build.ts | 2 + packages/kilo-vscode/script/local-bin.ts | 24 ++- packages/kilo-vscode/script/watch-cli.ts | 32 +-- packages/kilo-vscode/src/KiloProvider.ts | 19 +- packages/kilo-vscode/src/extension.ts | 9 +- .../src/kilo-provider/early-message.ts | 5 - .../browser-automation-service.ts | 166 ---------------- .../src/services/browser-automation/index.ts | 1 - .../src/services/cli-backend/cli-resources.ts | 36 ++++ .../services/cli-backend/server-manager.ts | 9 +- .../tests/unit/server-manager-utils.test.ts | 71 ++++++- packages/kilo-vscode/webview-ui/src/App.tsx | 3 + .../src/components/chat/WorldToolRenderer.tsx | 69 +++++++ .../src/components/settings/BrowserTab.tsx | 70 +++---- .../components/settings/ExperimentalTab.tsx | 13 ++ .../src/components/settings/settings-io.ts | 1 + .../kilo-vscode/webview-ui/src/i18n/ar.ts | 13 +- .../kilo-vscode/webview-ui/src/i18n/br.ts | 12 +- .../kilo-vscode/webview-ui/src/i18n/bs.ts | 12 +- .../kilo-vscode/webview-ui/src/i18n/da.ts | 12 +- .../kilo-vscode/webview-ui/src/i18n/de.ts | 12 +- .../kilo-vscode/webview-ui/src/i18n/en.ts | 12 +- .../kilo-vscode/webview-ui/src/i18n/es.ts | 12 +- .../kilo-vscode/webview-ui/src/i18n/fa.ts | 12 +- .../kilo-vscode/webview-ui/src/i18n/fr.ts | 12 +- .../kilo-vscode/webview-ui/src/i18n/it.ts | 12 +- .../kilo-vscode/webview-ui/src/i18n/ja.ts | 12 +- .../kilo-vscode/webview-ui/src/i18n/ko.ts | 13 +- .../kilo-vscode/webview-ui/src/i18n/nl.ts | 12 +- .../kilo-vscode/webview-ui/src/i18n/no.ts | 12 +- .../kilo-vscode/webview-ui/src/i18n/pl.ts | 12 +- .../kilo-vscode/webview-ui/src/i18n/ru.ts | 12 +- .../kilo-vscode/webview-ui/src/i18n/th.ts | 13 +- .../kilo-vscode/webview-ui/src/i18n/tr.ts | 13 +- .../kilo-vscode/webview-ui/src/i18n/uk.ts | 12 +- .../kilo-vscode/webview-ui/src/i18n/zh.ts | 12 +- .../kilo-vscode/webview-ui/src/i18n/zht.ts | 12 +- .../webview-ui/src/types/messages/config.ts | 20 +- .../src/types/messages/extension-messages.ts | 15 +- .../src/types/messages/webview-messages.ts | 5 - packages/kilo-world/src/config.ts | 16 +- packages/kilo-world/src/daemon/client.ts | 183 ++++++++++++++++-- packages/kilo-world/src/daemon/protocol.ts | 11 +- packages/kilo-world/src/daemon/server.ts | 5 + packages/kilo-world/src/types.ts | 5 +- packages/kilo-world/test/daemon.test.ts | 20 +- packages/kilo-world/test/launch.test.ts | 14 ++ .../opencode/src/kilocode/tool/registry.ts | 4 +- .../kilocode/tool-registry-indexing.test.ts | 4 +- 51 files changed, 688 insertions(+), 418 deletions(-) delete mode 100644 packages/kilo-vscode/src/services/browser-automation/browser-automation-service.ts delete mode 100644 packages/kilo-vscode/src/services/browser-automation/index.ts create mode 100644 packages/kilo-vscode/webview-ui/src/components/chat/WorldToolRenderer.tsx diff --git a/.changeset/world-tool.md b/.changeset/world-tool.md index c7a439ac01e..57beb6d1116 100644 --- a/.changeset/world-tool.md +++ b/.changeset/world-tool.md @@ -1,6 +1,7 @@ --- "@kilocode/cli": minor +"kilo-code": minor "@kilocode/world": minor --- -Add the built-in `world` tool for browser automation, letting Kilo navigate sites, click, type, and capture screenshots from the CLI. It runs in a persistent, isolated browser session with per-action permissions, CLI controls for headed browsing and system Chrome, and the required daemon bundled alongside the CLI. +Add the built-in `world` tool for browser automation, letting Kilo navigate sites, click, type, and capture screenshots from chat. It runs in a persistent, isolated browser session with per-action permissions, controls for headed browsing and system Chrome, and a bundled daemon. Configure it from the CLI command palette or Settings → Browser in the VS Code extension. diff --git a/packages/kilo-vscode/package.json b/packages/kilo-vscode/package.json index 71e266cd363..7eb411efd1f 100644 --- a/packages/kilo-vscode/package.json +++ b/packages/kilo-vscode/package.json @@ -1074,21 +1074,6 @@ "maximum": 24, "description": "Font size in pixels for the Kilo Code webview UI." }, - "kilo-code.new.browserAutomation.enabled": { - "type": "boolean", - "default": false, - "description": "Enable browser automation powered by Playwright. When enabled, the AI agent can interact with web pages in a visible Chrome window." - }, - "kilo-code.new.browserAutomation.useSystemChrome": { - "type": "boolean", - "default": true, - "description": "Use your system's installed Chrome browser instead of downloading a separate Chromium instance." - }, - "kilo-code.new.browserAutomation.headless": { - "type": "boolean", - "default": false, - "description": "Run browser automation in headless mode (no visible window). When disabled, you can watch the agent interact with the browser." - }, "kilo-code.new.attention.enabled": { "type": "boolean", "default": false, diff --git a/packages/kilo-vscode/script/build.ts b/packages/kilo-vscode/script/build.ts index c6dd9b4b9a2..8ce96add4fd 100644 --- a/packages/kilo-vscode/script/build.ts +++ b/packages/kilo-vscode/script/build.ts @@ -6,6 +6,7 @@ import { copyKiloSandboxWorker, copySandboxResources, copyTreeSitterResources, + copyWorldDaemon, } from "../src/services/cli-backend/cli-resources" import { ensureFfmpegForTarget } from "./ffmpeg-helper" @@ -83,6 +84,7 @@ for (const config of targets) { await copyTreeSitterResources(sourceBinary, targetBinary) await copySandboxResources(sourceBinary, targetBinary) await copyKiloSandboxWorker(sourceBinary, targetBinary) + await copyWorldDaemon(sourceBinary, targetBinary) if (config.binary !== "kilo.exe") { chmodSync(targetBinary, 0o755) diff --git a/packages/kilo-vscode/script/local-bin.ts b/packages/kilo-vscode/script/local-bin.ts index 9a2f11f550b..cbfae851fd6 100644 --- a/packages/kilo-vscode/script/local-bin.ts +++ b/packages/kilo-vscode/script/local-bin.ts @@ -6,11 +6,14 @@ import { copyKiloSandboxWorker, copySandboxResources, copyTreeSitterResources, + copyWorldDaemon, hasKiloSandboxWorker, hasTreeSitterResources, + hasWorldDaemon, kiloSandboxWorkerForBinary, sanitizeSandboxResources, } from "../src/services/cli-backend/cli-resources" +import { WorldDaemon } from "../../kilo-world/script/daemon" import { currentBwrapTarget, ensureBwrapForTarget } from "./bwrap-helper" import { currentFfmpegTarget, ensureFfmpegForTarget } from "./ffmpeg-helper" @@ -36,6 +39,7 @@ const coreDir = join(packagesDir, "core") const gatewayDir = join(packagesDir, "kilo-gateway") const indexingDir = join(packagesDir, "kilo-indexing") const sandboxDir = join(packagesDir, "kilo-sandbox") +const worldDir = join(packagesDir, "kilo-world") const targetBinDir = join(kiloVscodeDir, "bin") const binName = process.platform === "win32" ? "kilo.exe" : "kilo" @@ -53,7 +57,8 @@ async function cliSourceHash(): Promise { const gatewayResult = await $`git log -1 --format=%H -- .`.cwd(gatewayDir).quiet() const indexingResult = await $`git log -1 --format=%H -- .`.cwd(indexingDir).quiet() const sandboxResult = await $`git log -1 --format=%H -- .`.cwd(sandboxDir).quiet() - return `${opencodeResult.text().trim()}-${coreResult.text().trim()}-${gatewayResult.text().trim()}-${indexingResult.text().trim()}-${sandboxResult.text().trim()}` + const worldResult = await $`git log -1 --format=%H -- .`.cwd(worldDir).quiet() + return `${opencodeResult.text().trim()}-${coreResult.text().trim()}-${gatewayResult.text().trim()}-${indexingResult.text().trim()}-${sandboxResult.text().trim()}-${worldResult.text().trim()}` } catch { return null } @@ -66,12 +71,14 @@ async function isDirty(): Promise { const gatewayResult = await $`git status --porcelain -- .`.cwd(gatewayDir).quiet() const indexingResult = await $`git status --porcelain -- .`.cwd(indexingDir).quiet() const sandboxResult = await $`git status --porcelain -- .`.cwd(sandboxDir).quiet() + const worldResult = await $`git status --porcelain -- .`.cwd(worldDir).quiet() return ( opencodeResult.text().trim().length > 0 || coreResult.text().trim().length > 0 || gatewayResult.text().trim().length > 0 || indexingResult.text().trim().length > 0 || - sandboxResult.text().trim().length > 0 + sandboxResult.text().trim().length > 0 || + worldResult.text().trim().length > 0 ) } catch { return false @@ -109,7 +116,8 @@ async function findKiloBinaryInOpencodeDist(): Promise { const preferred = join(distDir, `@kilocode`, tag, "bin", binName) try { statSync(preferred) - if (!hasTreeSitterResources(preferred) || !hasKiloSandboxWorker(preferred)) return null + if (!hasTreeSitterResources(preferred) || !hasKiloSandboxWorker(preferred) || !hasWorldDaemon(preferred)) + return null return preferred } catch { // fall through to generic search @@ -135,7 +143,7 @@ async function findKiloBinaryInOpencodeDist(): Promise { continue } if (e.isFile() && (e.name === "kilo" || e.name === "kilo.exe") && basename(dirname(p)) === "bin") { - if (!hasTreeSitterResources(p) || !hasKiloSandboxWorker(p)) continue + if (!hasTreeSitterResources(p) || !hasKiloSandboxWorker(p) || !hasWorldDaemon(p)) continue return p } } @@ -212,6 +220,7 @@ async function writeSourceWrapper() { ) chmodSync(targetBinPath, 0o755) await bundleKiloSandboxWorker() + await WorldDaemon.copy(await WorldDaemon.bundle(), targetBinDir) await ensureLocalHelpers() const hash = await cliSourceHash() @@ -224,7 +233,11 @@ async function writeSourceWrapper() { async function main() { const targetFile = Bun.file(targetBinPath) const exists = await targetFile.exists() - const ready = exists && hasTreeSitterResources(targetBinPath) && hasKiloSandboxWorker(targetBinPath) + const ready = + exists && + hasTreeSitterResources(targetBinPath) && + hasKiloSandboxWorker(targetBinPath) && + hasWorldDaemon(targetBinPath) const stale = ready && !forceRebuild && (await isStale()) const rebuild = forceRebuild || stale || !ready @@ -267,6 +280,7 @@ async function main() { await copyTreeSitterResources(sourceBinPath, targetBinPath) await copySandboxResources(sourceBinPath, targetBinPath) await copyKiloSandboxWorker(sourceBinPath, targetBinPath) + await copyWorldDaemon(sourceBinPath, targetBinPath) chmodSync(targetBinPath, 0o755) await ensureLocalHelpers() diff --git a/packages/kilo-vscode/script/watch-cli.ts b/packages/kilo-vscode/script/watch-cli.ts index e7326362843..165a4864578 100644 --- a/packages/kilo-vscode/script/watch-cli.ts +++ b/packages/kilo-vscode/script/watch-cli.ts @@ -9,12 +9,17 @@ import { watch, chmodSync } from "node:fs" import { join, relative } from "node:path" import { $ } from "bun" -import { copySandboxResources, copyTreeSitterResources } from "../src/services/cli-backend/cli-resources" +import { + copySandboxResources, + copyTreeSitterResources, + copyWorldDaemon, +} from "../src/services/cli-backend/cli-resources" const kiloVscodeDir = join(import.meta.dir, "..") const packagesDir = join(kiloVscodeDir, "..") const opencodeDir = join(packagesDir, "opencode") const opencodeSrcDir = join(opencodeDir, "src") +const worldSrcDir = join(packagesDir, "kilo-world", "src") const targetBinDir = join(kiloVscodeDir, "bin") const targetBinPath = join(targetBinDir, "kilo") @@ -60,6 +65,7 @@ async function rebuild() { await $`cp ${source} ${targetBinPath}` await copyTreeSitterResources(source, targetBinPath) await copySandboxResources(source, targetBinPath) + await copyWorldDaemon(source, targetBinPath) chmodSync(targetBinPath, 0o755) const elapsed = ((performance.now() - start) / 1000).toFixed(1) @@ -81,16 +87,18 @@ log(`Watching ${relative(kiloVscodeDir, opencodeSrcDir)}/ for changes...`) const debounce = 500 let timer: ReturnType | null = null -watch(opencodeSrcDir, { recursive: true }, (_event, filename) => { - if (!filename) return - // Skip test files - if (filename.endsWith(".test.ts") || filename.endsWith(".test.tsx")) return - - if (timer) clearTimeout(timer) - timer = setTimeout(() => { - log(`Change detected: ${filename}`) - rebuild() - }, debounce) -}) +for (const dir of [opencodeSrcDir, worldSrcDir]) { + watch(dir, { recursive: true }, (_event, filename) => { + if (!filename) return + // Skip test files + if (filename.endsWith(".test.ts") || filename.endsWith(".test.tsx")) return + + if (timer) clearTimeout(timer) + timer = setTimeout(() => { + log(`Change detected: ${relative(packagesDir, join(dir, filename))}`) + rebuild() + }, debounce) + }) +} log("CLI watcher running. Press Ctrl+C to stop.") diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index 980f8e42722..53c67b3cac4 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -1017,7 +1017,6 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper connection: this.connectionService, dir: this.getWorkspaceDirectory(this.currentSession?.id), post: (msg) => this.postMessage(msg), - browserSettings: () => this.sendBrowserSettings(), exportTranscript: (sessionID) => this.handleExportSessionTranscript(sessionID), copy: (text) => vscode.env.clipboard.writeText(text), openSessions: (ids) => this.trackOpenSessions(ids), @@ -3971,7 +3970,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper /** * Handle a generic setting update from the webview. - * The key uses dot notation relative to `kilo-code.new` (e.g. "browserAutomation.enabled"). + * The key uses dot notation relative to `kilo-code.new`. */ private async handleUpdateSetting(key: string, value: unknown): Promise { if (key === "maxCost") { @@ -4042,7 +4041,6 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper // Re-send all settings to the webview so the UI reflects the reset this.postMessage(buildAutocompleteSettingsMessage()) await this.sendIndexingSettings() - this.sendBrowserSettings() this.sendNotificationSettings() this.sendTimelineSetting() this.postMessage(buildThroughputSettingMessage()) @@ -4059,21 +4057,6 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper vscode.window.showInformationMessage("Kilo Code settings have been reset to defaults.") } - /** - * Read the current browser automation settings and push them to the webview. - */ - private sendBrowserSettings(): void { - const config = vscode.workspace.getConfiguration("kilo-code.new.browserAutomation") - this.postMessage({ - type: "browserSettingsLoaded", - settings: { - enabled: config.get("enabled", false), - useSystemChrome: config.get("useSystemChrome", true), - headless: config.get("headless", false), - }, - }) - } - /** * Read the current Claude Code compatibility setting and push it to the webview. */ diff --git a/packages/kilo-vscode/src/extension.ts b/packages/kilo-vscode/src/extension.ts index b1549e3c479..b2fb3700ccb 100644 --- a/packages/kilo-vscode/src/extension.ts +++ b/packages/kilo-vscode/src/extension.ts @@ -16,7 +16,6 @@ import { registerAutocompleteProvider } from "./services/autocomplete" import { ensureBackendForAutocomplete } from "./services/autocomplete/ensure-backend" import { AutocompleteServiceManager } from "./services/autocomplete/AutocompleteServiceManager" import { AttentionService } from "./services/attention" -import { BrowserAutomationService } from "./services/browser-automation" import { TelemetryEventName, TelemetryProxy } from "./services/telemetry" import { registerCommitMessageService } from "./services/commit-message" import { registerCodeActions, registerTerminalActions, KiloCodeActionProvider } from "./services/code-actions" @@ -60,20 +59,15 @@ export function activate(context: vscode.ExtensionContext) { void context.workspaceState.update(RESTORE_KEY, restore) } - // Create browser automation service (manages Playwright MCP registration) - const browserAutomationService = new BrowserAutomationService(connectionService) - browserAutomationService.syncWithSettings() - // Create remote status service (one status bar item for all webviews) const remoteService = new RemoteStatusService() context.subscriptions.push(remoteService) connectionService.setRemoteService(remoteService) - // Re-register browser automation MCP server on CLI backend reconnect, configure telemetry, + // Configure telemetry, // set remote service client, and reload autocomplete so it picks up the now-available backend connection. const unsubscribeStateChange = connectionService.onStateChange((state) => { if (state === "connected") { - browserAutomationService.reregisterIfEnabled() const config = connectionService.getServerConfig() if (config) { telemetry.configure(config.baseUrl, config.password) @@ -582,7 +576,6 @@ export function activate(context: vscode.ExtensionContext) { shuttingDown = true unsubscribeStateChange() attention.dispose() - browserAutomationService.dispose() provider.dispose() notebookBridge.dispose() connectionService.dispose() diff --git a/packages/kilo-vscode/src/kilo-provider/early-message.ts b/packages/kilo-vscode/src/kilo-provider/early-message.ts index f67a2144fa1..ac46709f774 100644 --- a/packages/kilo-vscode/src/kilo-provider/early-message.ts +++ b/packages/kilo-vscode/src/kilo-provider/early-message.ts @@ -13,7 +13,6 @@ type Ctx = { connection: KiloConnectionService dir: string post: (msg: unknown) => void - browserSettings: () => void exportTranscript: (sessionID: string) => Promise copy: (text: string) => PromiseLike openSessions: (ids: string[]) => void @@ -64,9 +63,5 @@ export async function routeEarlyMessage( ctx.post(buildThroughputSettingMessage()) return true } - if (message.type === "requestBrowserSettings") { - ctx.browserSettings() - return true - } return await routeInputToolMessage(message, { connection: ctx.connection, dir: ctx.dir, post: ctx.post }) } diff --git a/packages/kilo-vscode/src/services/browser-automation/browser-automation-service.ts b/packages/kilo-vscode/src/services/browser-automation/browser-automation-service.ts deleted file mode 100644 index 5afdf98a8dd..00000000000 --- a/packages/kilo-vscode/src/services/browser-automation/browser-automation-service.ts +++ /dev/null @@ -1,166 +0,0 @@ -import * as vscode from "vscode" -import type { KiloClient } from "@kilocode/sdk/v2/client" -import type { KiloConnectionService } from "../cli-backend" - -type BrowserAutomationState = "disabled" | "registering" | "connected" | "failed" | "disconnected" - -export class BrowserAutomationService implements vscode.Disposable { - private state: BrowserAutomationState = "disabled" - private disposables: vscode.Disposable[] = [] - - // MCP server name used when registering with the CLI backend - private static readonly MCP_SERVER_NAME = "kilo-playwright" - - constructor(private readonly connectionService: KiloConnectionService) { - // Listen for settings changes - this.disposables.push( - vscode.workspace.onDidChangeConfiguration((e) => { - if (e.affectsConfiguration("kilo-code.new.browserAutomation")) { - this.syncWithSettings() - } - }), - ) - } - - /** - * Read settings and enable/disable accordingly. - * Called on construction and when settings change. - */ - async syncWithSettings(): Promise { - const config = vscode.workspace.getConfiguration("kilo-code.new.browserAutomation") - const enabled = config.get("enabled", false) - - if (enabled) { - await this.register() - } else { - await this.unregister() - } - } - - /** - * Re-register the MCP server after CLI backend reconnects. - * Should be called from the connection state change handler. - */ - async reregisterIfEnabled(): Promise { - const config = vscode.workspace.getConfiguration("kilo-code.new.browserAutomation") - const enabled = config.get("enabled", false) - if (enabled) { - await this.register() - } - } - - /** - * Register the Playwright MCP server with the CLI backend. - */ - private async register(): Promise { - this.setState("registering") - - const client = this.getClient() - if (!client) { - console.error("[Kilo New] BrowserAutomationService: No SDK client available") - this.setState("failed") - return - } - - const config = vscode.workspace.getConfiguration("kilo-code.new.browserAutomation") - const useSystemChrome = config.get("useSystemChrome", true) - const headless = config.get("headless", false) - - // Build the command for the Playwright MCP server - const command = ["npx", "@playwright/mcp@latest"] - if (headless) { - command.push("--headless") - } - if (useSystemChrome) { - command.push("--browser", "chrome") - } - - try { - const directory = this.getWorkspaceDirectory() - const { data: status } = await client.mcp.add( - { - name: BrowserAutomationService.MCP_SERVER_NAME, - config: { - type: "local", - command, - enabled: true, - timeout: 60000, - }, - directory, - }, - { throwOnError: true }, - ) - - const serverStatus = status[BrowserAutomationService.MCP_SERVER_NAME] - if (serverStatus?.status === "connected") { - this.setState("connected") - } else if (serverStatus?.status === "failed") { - console.error( - "[Kilo New] BrowserAutomationService: MCP server failed:", - (serverStatus as { error?: string }).error, - ) - this.setState("failed") - } else { - this.setState("disconnected") - } - } catch (error) { - console.error("[Kilo New] BrowserAutomationService: Failed to register MCP server:", error) - this.setState("failed") - } - } - - /** - * Unregister/disconnect the Playwright MCP server. - */ - private async unregister(): Promise { - if (this.state === "disabled") { - return - } - - const client = this.getClient() - if (client) { - try { - const directory = this.getWorkspaceDirectory() - await client.mcp.disconnect( - { name: BrowserAutomationService.MCP_SERVER_NAME, directory }, - { throwOnError: true }, - ) - } catch (error) { - console.error("[Kilo New] BrowserAutomationService: Failed to disconnect MCP server:", error) - } - } - - this.setState("disabled") - } - - private getClient(): KiloClient | null { - try { - return this.connectionService.getClient() - } catch { - return null - } - } - - private getWorkspaceDirectory(): string { - const folders = vscode.workspace.workspaceFolders - if (folders && folders.length > 0) { - return folders[0].uri.fsPath - } - return process.cwd() - } - - private setState(state: BrowserAutomationState): void { - if (this.state === state) { - return - } - console.log(`[Kilo New] BrowserAutomationService: State ${this.state} → ${state}`) - this.state = state - } - - dispose(): void { - for (const d of this.disposables) { - d.dispose() - } - this.disposables = [] - } -} diff --git a/packages/kilo-vscode/src/services/browser-automation/index.ts b/packages/kilo-vscode/src/services/browser-automation/index.ts deleted file mode 100644 index da2a8e488c7..00000000000 --- a/packages/kilo-vscode/src/services/browser-automation/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { BrowserAutomationService } from "./browser-automation-service" diff --git a/packages/kilo-vscode/src/services/cli-backend/cli-resources.ts b/packages/kilo-vscode/src/services/cli-backend/cli-resources.ts index 5caf3b1387b..6e8b6b2d253 100644 --- a/packages/kilo-vscode/src/services/cli-backend/cli-resources.ts +++ b/packages/kilo-vscode/src/services/cli-backend/cli-resources.ts @@ -6,6 +6,8 @@ import * as path from "path" const dir = "tree-sitter" const runtime = "tree-sitter.wasm" const kiloSandboxWorker = "kilo-sandbox-mutation-worker.js" +const worldDaemon = "world-daemon.cjs" +const worldManifest = "world-daemon.assets.json" const bwrap = "bwrap" const bwrapLicense = path.join("licenses", "bubblewrap") const bwrapLicenseFiles = ["NOTICE", "COPYING", "MUSL-COPYRIGHT", "build.ts"] @@ -43,6 +45,20 @@ export function hasKiloSandboxWorker(file: string): boolean { return fs.existsSync(kiloSandboxWorkerForBinary(file)) } +export function worldDaemonForBinary(file: string): string { + const p = paths(file) + return p.join(p.dirname(file), worldDaemon) +} + +export function worldDaemonManifestForBinary(file: string): string { + const p = paths(file) + return p.join(p.dirname(file), worldManifest) +} + +export function hasWorldDaemon(file: string): boolean { + return fs.existsSync(worldDaemonForBinary(file)) && fs.existsSync(worldDaemonManifestForBinary(file)) +} + export async function copyTreeSitterResources(source: string, target: string): Promise { const from = treeSitterDirForBinary(source) const to = treeSitterDirForBinary(target) @@ -94,6 +110,26 @@ export async function copyKiloSandboxWorker(source: string, target: string): Pro await fs.promises.copyFile(from, to) } +export async function copyWorldDaemon(source: string, target: string): Promise { + const manifest = worldDaemonManifestForBinary(source) + if (!fs.existsSync(manifest)) throw new Error(`Kilo world daemon manifest not found at ${manifest}`) + const value: unknown = JSON.parse(await fs.promises.readFile(manifest, "utf8")) + if (!Array.isArray(value) || !value.every((file) => typeof file === "string" && path.basename(file) === file)) { + throw new Error(`Invalid Kilo world daemon manifest at ${manifest}`) + } + if (!value.includes(worldDaemon)) throw new Error(`Kilo world daemon manifest does not include ${worldDaemon}`) + const from = path.dirname(source) + const to = path.dirname(target) + await Promise.all( + value.map(async (file) => { + const input = path.join(from, file) + if (!fs.existsSync(input)) throw new Error(`Kilo world daemon resource not found at ${input}`) + await fs.promises.copyFile(input, path.join(to, file)) + }), + ) + await fs.promises.copyFile(manifest, worldDaemonManifestForBinary(target)) +} + function cacheRoot() { const root = process.env.XDG_CACHE_HOME ?? path.join(os.homedir(), ".cache") return path.join(root, "kilo-vscode", "bwrap") diff --git a/packages/kilo-vscode/src/services/cli-backend/server-manager.ts b/packages/kilo-vscode/src/services/cli-backend/server-manager.ts index 9481897cc5b..5dbca7f3b7f 100644 --- a/packages/kilo-vscode/src/services/cli-backend/server-manager.ts +++ b/packages/kilo-vscode/src/services/cli-backend/server-manager.ts @@ -28,8 +28,13 @@ export function resolveIndexingEnv(folders: readonly WorkspaceFolderLike[] | und return { KILO_DISABLE_CODEBASE_INDEXING: "vscode-no-workspace" } } -export function resolveManagedServerEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { - return { ...env, KILO_DISABLE_CHANNEL_DB: "true" } +export function resolveManagedServerEnv(env: NodeJS.ProcessEnv, runtime = process.execPath): NodeJS.ProcessEnv { + return { + ...env, + KILO_DISABLE_CHANNEL_DB: "true", + KILO_WORLD_NODE: env.KILO_WORLD_NODE ?? runtime, + KILO_WORLD_NODE_ELECTRON: env.KILO_WORLD_NODE_ELECTRON ?? (env.KILO_WORLD_NODE ? "0" : "1"), + } } export class ServerManager { diff --git a/packages/kilo-vscode/tests/unit/server-manager-utils.test.ts b/packages/kilo-vscode/tests/unit/server-manager-utils.test.ts index db9ac71a419..eafc06e199e 100644 --- a/packages/kilo-vscode/tests/unit/server-manager-utils.test.ts +++ b/packages/kilo-vscode/tests/unit/server-manager-utils.test.ts @@ -10,10 +10,14 @@ import { copyKiloSandboxWorker, copySandboxResources, copyTreeSitterResources, + copyWorldDaemon, + hasWorldDaemon, resolveTreeSitterEnv, kiloSandboxWorkerForBinary, treeSitterDirForBinary, treeSitterDirForExtension, + worldDaemonForBinary, + worldDaemonManifestForBinary, } from "../../src/services/cli-backend/cli-resources" import * as fs from "fs/promises" import * as os from "os" @@ -63,7 +67,7 @@ describe("parseServerPort", () => { }) }) -describe("cli tree-sitter resources", () => { +describe("CLI companion resources", () => { it("resolves resources next to the VS Code bundled CLI", () => { const root = "/Users/test/.vscode/extensions/kilocode.kilo-code-7.2.50-darwin-arm64" const bin = `${root}/bin/kilo` @@ -71,6 +75,8 @@ describe("cli tree-sitter resources", () => { expect(treeSitterDirForBinary(bin)).toBe(`${root}/bin/tree-sitter`) expect(treeSitterDirForExtension(root)).toBe(`${root}/bin/tree-sitter`) expect(kiloSandboxWorkerForBinary(bin)).toBe(`${root}/bin/kilo-sandbox-mutation-worker.js`) + expect(worldDaemonForBinary(bin)).toBe(`${root}/bin/world-daemon.cjs`) + expect(worldDaemonManifestForBinary(bin)).toBe(`${root}/bin/world-daemon.assets.json`) expect(resolveTreeSitterEnv(root)).toEqual({ KILO_TREE_SITTER_WASM_DIR: `${root}/bin/tree-sitter` }) }) @@ -81,6 +87,8 @@ describe("cli tree-sitter resources", () => { expect(treeSitterDirForBinary(bin)).toBe(String.raw`${root}\bin\tree-sitter`) expect(treeSitterDirForExtension(root)).toBe(String.raw`${root}\bin\tree-sitter`) expect(kiloSandboxWorkerForBinary(bin)).toBe(String.raw`${root}\bin\kilo-sandbox-mutation-worker.js`) + expect(worldDaemonForBinary(bin)).toBe(String.raw`${root}\bin\world-daemon.cjs`) + expect(worldDaemonManifestForBinary(bin)).toBe(String.raw`${root}\bin\world-daemon.assets.json`) expect(resolveTreeSitterEnv(root)).toEqual({ KILO_TREE_SITTER_WASM_DIR: String.raw`${root}\bin\tree-sitter`, }) @@ -103,6 +111,45 @@ describe("cli tree-sitter resources", () => { } }) + it("copies the world daemon with the packaged CLI binary", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "kilo-vscode-world-daemon-")) + try { + const source = path.join(root, "dist", "@kilocode", "cli-darwin-arm64", "bin", "kilo") + const target = path.join(root, "extension", "bin", "kilo") + await fs.mkdir(path.dirname(source), { recursive: true }) + await fs.mkdir(path.dirname(target), { recursive: true }) + await fs.writeFile(worldDaemonForBinary(source), "daemon") + await fs.writeFile(path.join(path.dirname(source), "appIcon-test.png"), "icon") + await fs.writeFile(worldDaemonManifestForBinary(source), JSON.stringify(["world-daemon.cjs", "appIcon-test.png"])) + + await copyWorldDaemon(source, target) + + expect(hasWorldDaemon(target)).toBe(true) + expect(await fs.readFile(worldDaemonForBinary(target), "utf8")).toBe("daemon") + expect(await fs.readFile(path.join(path.dirname(target), "appIcon-test.png"), "utf8")).toBe("icon") + expect(JSON.parse(await fs.readFile(worldDaemonManifestForBinary(target), "utf8"))).toEqual([ + "world-daemon.cjs", + "appIcon-test.png", + ]) + } finally { + await fs.rm(root, { recursive: true, force: true }) + } + }) + + it("rejects a packaged CLI without the world daemon", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "kilo-vscode-world-daemon-missing-")) + try { + const source = path.join(root, "dist", "bin", "kilo") + const target = path.join(root, "extension", "bin", "kilo") + await fs.mkdir(path.dirname(source), { recursive: true }) + await fs.mkdir(path.dirname(target), { recursive: true }) + + await expect(copyWorldDaemon(source, target)).rejects.toThrow("Kilo world daemon manifest not found") + } finally { + await fs.rm(root, { recursive: true, force: true }) + } + }) + it("copies runtime and language WASMs with the packaged CLI binary", async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), "kilo-vscode-tree-sitter-")) try { @@ -316,9 +363,29 @@ describe("server workspace helpers", () => { }) it("uses the shared database for the managed backend while preserving the environment", () => { - expect(resolveManagedServerEnv({ PATH: "/usr/bin", KILO_DISABLE_CHANNEL_DB: "false" })).toEqual({ + expect( + resolveManagedServerEnv({ PATH: "/usr/bin", KILO_DISABLE_CHANNEL_DB: "false" }, "/Applications/Code"), + ).toEqual({ PATH: "/usr/bin", KILO_DISABLE_CHANNEL_DB: "true", + KILO_WORLD_NODE: "/Applications/Code", + KILO_WORLD_NODE_ELECTRON: "1", + }) + }) + + it("preserves an explicit world Node runtime", () => { + expect(resolveManagedServerEnv({ KILO_WORLD_NODE: "/custom/node" }, "/Applications/Code")).toMatchObject({ + KILO_WORLD_NODE: "/custom/node", + KILO_WORLD_NODE_ELECTRON: "0", }) }) + + it("does not overwrite an explicit world runtime kind", () => { + expect( + resolveManagedServerEnv( + { KILO_WORLD_NODE: "/custom/electron", KILO_WORLD_NODE_ELECTRON: "1" }, + "/Applications/Code", + ), + ).toMatchObject({ KILO_WORLD_NODE: "/custom/electron", KILO_WORLD_NODE_ELECTRON: "1" }) + }) }) diff --git a/packages/kilo-vscode/webview-ui/src/App.tsx b/packages/kilo-vscode/webview-ui/src/App.tsx index 7fa356e8482..9e4b46daf69 100644 --- a/packages/kilo-vscode/webview-ui/src/App.tsx +++ b/packages/kilo-vscode/webview-ui/src/App.tsx @@ -13,12 +13,15 @@ import { ChatView } from "./components/chat" import { SidebarEmptyState } from "./components/chat/SidebarEmptyState" import { registerExpandedTaskTool } from "./components/chat/TaskToolExpanded" import { registerVscodeToolOverrides } from "./components/chat/VscodeToolOverrides" +import { registerWorldTool } from "./components/chat/WorldToolRenderer" // Override the upstream "task" tool renderer with the fully-expanded version // that shows child session parts inline in the VS Code sidebar. registerExpandedTaskTool() // Apply VS Code sidebar preferences to other tools (e.g. bash expanded by default). registerVscodeToolOverrides() +// Render the world tool's screenshot attachments inline. +registerWorldTool() import HistoryView from "./components/history/HistoryView" import { MigrationWizard } from "./components/migration" // legacy-migration import type { Message as SDKMessage, Part as SDKPart } from "@kilocode/sdk/v2" diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/WorldToolRenderer.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/WorldToolRenderer.tsx new file mode 100644 index 00000000000..d53fc923a2c --- /dev/null +++ b/packages/kilo-vscode/webview-ui/src/components/chat/WorldToolRenderer.tsx @@ -0,0 +1,69 @@ +import { Component, createMemo, For, Show } from "solid-js" +import { BasicTool } from "@kilocode/kilo-ui/basic-tool" +import { ToolRegistry, type ToolProps } from "@kilocode/kilo-ui/message-part" +import { ImagePreview } from "@kilocode/kilo-ui/image-preview" +import { useDialog } from "@kilocode/kilo-ui/context/dialog" +import { useI18n } from "@kilocode/kilo-ui/context/i18n" +import { Markdown } from "@kilocode/kilo-ui/markdown" + +function WorldToolRenderer(props: ToolProps) { + const dialog = useDialog() + const i18n = useI18n() + const images = createMemo(() => (props.attachments ?? []).filter((f) => f.mime.startsWith("image/") && f.url)) + const preview = (url: string, alt?: string) => dialog.show(() => ) + const title = createMemo(() => (typeof props.metadata?.title === "string" ? props.metadata.title : "World")) + const subtitle = createMemo(() => { + const meta = props.metadata + if (!meta || typeof meta !== "object") return "" + const actions = typeof meta["actions"] === "number" ? meta["actions"] : 0 + const ms = typeof meta["durationMs"] === "number" ? meta["durationMs"] : 0 + if (!actions) return "" + return `${actions} action${actions === 1 ? "" : "s"} · ${ms}ms` + }) + + return ( + <> + + + {(output) => ( +
+ +
+ )} +
+
+ 0}> +
+ + {(file) => ( +
preview(file.url, file.filename)}> + {file.filename +
+ )} +
+
+
+ + ) +} + +export function registerWorldTool() { + ToolRegistry.register({ + name: "world", + render: WorldToolRenderer as Component, + }) +} diff --git a/packages/kilo-vscode/webview-ui/src/components/settings/BrowserTab.tsx b/packages/kilo-vscode/webview-ui/src/components/settings/BrowserTab.tsx index eee016f3f51..592b11c833d 100644 --- a/packages/kilo-vscode/webview-ui/src/components/settings/BrowserTab.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/settings/BrowserTab.tsx @@ -1,10 +1,8 @@ -import { Component, Show, createSignal, onCleanup, onMount } from "solid-js" +import { Component, Show } from "solid-js" import { Switch } from "@kilocode/kilo-ui/switch" import { Card } from "@kilocode/kilo-ui/card" -import { useVSCode } from "../../context/vscode" -import { useLanguage } from "../../context/language" import { useConfig } from "../../context/config" -import type { BrowserSettings } from "../../types/messages" +import { useLanguage } from "../../context/language" import SettingsRow from "./SettingsRow" const Header: Component<{ title: string }> = (props) => ( @@ -24,31 +22,24 @@ const Header: Component<{ title: string }> = (props) => ( ) const BrowserTab: Component = () => { - const { postMessage, onMessage } = useVSCode() + const { config, globalConfig, projectConfig, updateConfig, updateGlobalConfig } = useConfig() const { t } = useLanguage() - const { globalConfig, projectConfig, updateGlobalConfig } = useConfig() - - const [settings, setSettings] = createSignal({ - enabled: false, - useSystemChrome: true, - headless: false, - }) + const enabled = () => config().experimental?.world_browser !== false + const headless = () => config().world?.browser?.headless ?? true + const chrome = () => config().world?.browser?.use_system_chrome ?? false - onMount(() => { - postMessage({ type: "requestBrowserSettings" }) - }) + const setEnabled = (value: boolean) => { + updateConfig({ experimental: { ...config().experimental, world_browser: value } }) + } - // Subscribe outside onMount to catch early pushes (per AGENTS.md pattern) - const unsubscribe = onMessage((msg) => { - if (msg.type === "browserSettingsLoaded") { - setSettings(msg.settings) - } - }) - onCleanup(unsubscribe) + const setHeadless = (value: boolean) => { + updateConfig({ world: { ...config().world, browser: { ...config().world?.browser, headless: value } } }) + } - const update = (key: keyof BrowserSettings, value: boolean) => { - setSettings((prev) => ({ ...prev, [key]: value })) - postMessage({ type: "updateSetting", key: `browserAutomation.${key}`, value }) + const setChrome = (value: boolean) => { + updateConfig({ + world: { ...config().world, browser: { ...config().world?.browser, use_system_chrome: value } }, + }) } const updateWebsearch = (checked: boolean) => { @@ -120,42 +111,31 @@ const BrowserTab: Component = () => { {t("settings.browser.description")}

- {/* Enable toggle */} - update("enabled", checked)} hideLabel> + {t("settings.browser.enable.title")} - {/* Use System Chrome */} - update("useSystemChrome", checked)} - hideLabel - > - {t("settings.browser.systemChrome.title")} + + {t("settings.browser.headless.title")} - {/* Headless mode */} - update("headless", checked)} - hideLabel - > - {t("settings.browser.headless.title")} + + {t("settings.browser.systemChrome.title")} diff --git a/packages/kilo-vscode/webview-ui/src/components/settings/ExperimentalTab.tsx b/packages/kilo-vscode/webview-ui/src/components/settings/ExperimentalTab.tsx index badc9a87b56..f3b79aa1872 100644 --- a/packages/kilo-vscode/webview-ui/src/components/settings/ExperimentalTab.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/settings/ExperimentalTab.tsx @@ -158,6 +158,19 @@ const ExperimentalTab: Component = () => { + + updateExperimental("world_browser", checked)} + hideLabel + > + {language.t("settings.experimental.worldBrowser.title")} + + + = [ "sandbox", "indexing", "experimental", + "world", ] export type ImportError = "invalidJson" | "invalidConfig" | "tooLarge" diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ar.ts b/packages/kilo-vscode/webview-ui/src/i18n/ar.ts index 6c425940a8f..2ac4ae21414 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ar.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ar.ts @@ -799,13 +799,15 @@ export const dict = { "settings.agentBehaviour.subtab.skills": "المهارات", "settings.browser.description": - "عند التفعيل، يمكن لوكيل الذكاء الاصطناعي التفاعل مع صفحات الويب — التنقل والنقر والكتابة والتقاط لقطات الشاشة. سيتم فتح نافذة Chrome حتى تتمكن من مشاهدة الوكيل أثناء العمل.", + "تحكم في متصفح بدون واجهة للتنقل والنقر والكتابة والتقاط لقطات الشاشة. يعمل المتصفح بدون نافذة مرئية افتراضيًا، وستظهر صورة مضمّنة للصفحة بعد كل إجراء مرئي.", "settings.browser.enable.title": "تفعيل أتمتة المتصفح", - "settings.browser.enable.description": "تسجيل خادم Playwright MCP مع الواجهة الخلفية لـ CLI.", + "settings.browser.enable.description": "فعّل أداة World لأتمتة المتصفح.", "settings.browser.systemChrome.title": "استخدام Chrome النظام", - "settings.browser.systemChrome.description": "استخدم متصفح Chrome المثبت لديك بدلاً من نسخة Chromium منفصلة.", + "settings.browser.systemChrome.description": + "استخدم Chrome المثبت بدلاً من Chromium منفصل، مع الرجوع إلى Chromium إذا لم يتم العثور على Chrome.", "settings.browser.headless.title": "الوضع بدون واجهة", - "settings.browser.headless.description": "التشغيل في الوضع بدون واجهة (بدون نافذة متصفح مرئية).", + "settings.browser.headless.description": + "شغّل المتصفح بدون نافذة مرئية. عطّل هذا الخيار لفتح نافذة Chrome يمكنك مشاهدتها.", "settings.language.description": 'اختر اللغة لواجهة Kilo Code. "تلقائي" يستخدم لغة عرض VS Code.', "settings.language.auto": "تلقائي (لغة VS Code)", @@ -841,6 +843,9 @@ export const dict = { "settings.experimental.batch.description": "تمكين المعالجة الدفعية لاستدعاءات الأدوات", "settings.experimental.codebaseSearch.title": "بحث في قاعدة الكود", "settings.experimental.codebaseSearch.description": "تمكين البحث بالذكاء الاصطناعي باللغة الطبيعية عبر قاعدة الكود", + "settings.experimental.worldBrowser.title": "متصفح World", + "settings.experimental.worldBrowser.description": + "فعّل أداة World للمتصفح. تعمل بدون واجهة افتراضيًا وتعيد صورة مضمّنة بعد كل إجراء مرئي. عطّلها لإخفاء إمكانات المتصفح عن الوكيل.", "settings.experimental.imageGeneration.title": "توليد الصور", "settings.experimental.imageGeneration.description": "تمكين توليد الصور بالذكاء الاصطناعي", "settings.experimental.imageGenerationModel.title": "نموذج الصور", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/br.ts b/packages/kilo-vscode/webview-ui/src/i18n/br.ts index 1a7d9ae17ba..0958629d4c0 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/br.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/br.ts @@ -823,14 +823,15 @@ export const dict = { "settings.agentBehaviour.subtab.skills": "Habilidades", "settings.browser.description": - "Quando ativado, o agente de IA pode interagir com páginas da web — navegar, clicar, digitar e capturar telas. Uma janela do Chrome será aberta para que você possa acompanhar o trabalho do agente.", + "Controle um navegador headless para navegar, clicar, digitar e capturar telas. Por padrão, o navegador roda sem uma janela visível, mas mostra uma imagem da página após cada ação visual.", "settings.browser.enable.title": "Ativar Automação do Navegador", - "settings.browser.enable.description": "Registrar o servidor Playwright MCP no backend CLI.", + "settings.browser.enable.description": "Ative a ferramenta World para automação do navegador.", "settings.browser.systemChrome.title": "Usar Chrome do Sistema", "settings.browser.systemChrome.description": - "Usar o navegador Chrome instalado em vez de uma instância separada do Chromium.", + "Use o Chrome instalado em vez de uma instância separada do Chromium. Usa o Chromium se o Chrome não for encontrado.", "settings.browser.headless.title": "Modo Headless", - "settings.browser.headless.description": "Executar em modo headless (sem janela do navegador visível).", + "settings.browser.headless.description": + "Execute sem uma janela visível. Desative para abrir uma janela do Chrome que você possa acompanhar.", "settings.language.description": 'Escolha o idioma da interface do Kilo Code. "Auto" usa o idioma de exibição do VS Code.', @@ -871,6 +872,9 @@ export const dict = { "settings.experimental.codebaseSearch.title": "Pesquisa de código", "settings.experimental.codebaseSearch.description": "Ativar pesquisa por linguagem natural com IA em toda a base de código", + "settings.experimental.worldBrowser.title": "Navegador World", + "settings.experimental.worldBrowser.description": + "Ative a ferramenta de navegador World. Ela roda em modo headless por padrão e retorna uma imagem após cada ação visual. Desative para ocultar os recursos de navegador do agente.", "settings.experimental.imageGeneration.title": "Geração de imagens", "settings.experimental.imageGeneration.description": "Ativar geração de imagens por IA", "settings.experimental.imageGenerationModel.title": "Modelo de imagem", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/bs.ts b/packages/kilo-vscode/webview-ui/src/i18n/bs.ts index 509f0265201..fa2c004bbd4 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/bs.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/bs.ts @@ -820,14 +820,15 @@ export const dict = { "settings.agentBehaviour.subtab.skills": "Vještine", "settings.browser.description": - "Kada je omogućeno, AI agent može komunicirati s web stranicama — navigirati, klikati, tipkati i snimati ekran. Chrome prozor će se otvoriti kako biste mogli pratiti rad agenta.", + "Upravljajte headless preglednikom za navigaciju, klikanje, tipkanje i snimanje ekrana. Preglednik se podrazumijevano pokreće bez vidljivog prozora, ali prikazuje sliku stranice nakon svake vizuelne radnje.", "settings.browser.enable.title": "Omogući automatizaciju preglednika", - "settings.browser.enable.description": "Registriraj Playwright MCP server s CLI pozadinom.", + "settings.browser.enable.description": "Omogućite alat World za automatizaciju preglednika.", "settings.browser.systemChrome.title": "Koristi sistemski Chrome", "settings.browser.systemChrome.description": - "Koristite instalirani Chrome preglednik umjesto zasebne Chromium instance.", + "Koristite instalirani Chrome umjesto zasebnog Chromiuma. Vraća se na Chromium ako Chrome nije pronađen.", "settings.browser.headless.title": "Headless način", - "settings.browser.headless.description": "Pokreni u headless načinu (bez vidljivog prozora preglednika).", + "settings.browser.headless.description": + "Pokrenite bez vidljivog prozora. Isključite da otvorite Chrome prozor koji možete pratiti.", "settings.language.description": 'Odaberite jezik za Kilo Code sučelje. "Auto" koristi jezik prikaza VS Code-a.', "settings.language.auto": "Auto (VS Code jezik)", @@ -865,6 +866,9 @@ export const dict = { "settings.experimental.batch.description": "Omogući batch obradu poziva alata", "settings.experimental.codebaseSearch.title": "Pretraga koda", "settings.experimental.codebaseSearch.description": "Omogući AI pretragu prirodnim jezikom kroz bazu koda", + "settings.experimental.worldBrowser.title": "World preglednik", + "settings.experimental.worldBrowser.description": + "Omogućite World alat za preglednik. Podrazumijevano radi bez prozora i vraća sliku nakon svake vizuelne radnje. Isključite da sakrijete mogućnosti preglednika od agenta.", "settings.experimental.imageGeneration.title": "Generisanje slika", "settings.experimental.imageGeneration.description": "Omogući AI generisanje slika", "settings.experimental.imageGenerationModel.title": "Model slike", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/da.ts b/packages/kilo-vscode/webview-ui/src/i18n/da.ts index 4f1d6c9034c..16fe680f3a6 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/da.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/da.ts @@ -819,14 +819,15 @@ export const dict = { "settings.agentBehaviour.subtab.skills": "Færdigheder", "settings.browser.description": - "Når aktiveret kan AI-agenten interagere med websider — navigere, klikke, skrive og tage skærmbilleder. Et Chrome-vindue åbnes, så du kan se agenten arbejde.", + "Styr en headless-browser til at navigere, klikke, skrive og tage skærmbilleder. Browseren kører som standard uden et synligt vindue, men viser et indlejret billede efter hver visuel handling.", "settings.browser.enable.title": "Aktivér browserautomatisering", - "settings.browser.enable.description": "Registrér Playwright MCP-serveren hos CLI-backend'en.", + "settings.browser.enable.description": "Aktivér World-værktøjet til browserautomatisering.", "settings.browser.systemChrome.title": "Brug system-Chrome", "settings.browser.systemChrome.description": - "Brug din installerede Chrome-browser i stedet for en separat Chromium-instans.", + "Brug din installerede Chrome-browser i stedet for en separat Chromium-instans. Falder tilbage til Chromium, hvis Chrome ikke findes.", "settings.browser.headless.title": "Headless-tilstand", - "settings.browser.headless.description": "Kør i headless-tilstand (intet synligt browservindue).", + "settings.browser.headless.description": + "Kør uden et synligt browservindue. Slå fra for at åbne et Chrome-vindue, du kan følge.", "settings.language.description": 'Vælg sproget til Kilo Code-brugerfladen. "Auto" bruger VS Codes visningssprog.', "settings.language.auto": "Auto (VS Code-sprog)", @@ -864,6 +865,9 @@ export const dict = { "settings.experimental.batch.description": "Aktiver batchbehandling af flere værktøjskald", "settings.experimental.codebaseSearch.title": "Kodesøgning", "settings.experimental.codebaseSearch.description": "Aktiver AI-drevet naturlig sprogsøgning på tværs af kodebasen", + "settings.experimental.worldBrowser.title": "World-browser", + "settings.experimental.worldBrowser.description": + "Aktivér World-browserværktøjet. Det kører som standard headless og returnerer et billede efter hver visuel handling. Slå fra for at skjule browserfunktioner for agenten.", "settings.experimental.imageGeneration.title": "Billedgenerering", "settings.experimental.imageGeneration.description": "Aktiver AI-billedgenerering", "settings.experimental.imageGenerationModel.title": "Billedmodel", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/de.ts b/packages/kilo-vscode/webview-ui/src/i18n/de.ts index 7c1366c7261..630f6e6740c 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/de.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/de.ts @@ -835,14 +835,15 @@ export const dict = { "settings.agentBehaviour.subtab.skills": "Fähigkeiten", "settings.browser.description": - "Wenn aktiviert, kann der KI-Agent mit Webseiten interagieren — navigieren, klicken, tippen und Screenshots erstellen. Ein Chrome-Fenster wird geöffnet, damit Sie dem Agenten bei der Arbeit zusehen können.", + "Steuern Sie einen Headless-Browser zum Navigieren, Klicken, Tippen und Erstellen von Screenshots. Der Browser läuft standardmäßig ohne sichtbares Fenster, zeigt aber nach jeder visuellen Aktion ein eingebettetes Bild der Seite.", "settings.browser.enable.title": "Browser-Automatisierung aktivieren", - "settings.browser.enable.description": "Den Playwright-MCP-Server beim CLI-Backend registrieren.", + "settings.browser.enable.description": "Das World-Werkzeug zur Browserautomatisierung aktivieren.", "settings.browser.systemChrome.title": "System-Chrome verwenden", "settings.browser.systemChrome.description": - "Verwenden Sie Ihren installierten Chrome-Browser anstelle einer separaten Chromium-Instanz.", + "Den installierten Chrome-Browser statt einer separaten Chromium-Instanz verwenden. Fällt auf Chromium zurück, wenn Chrome nicht gefunden wird.", "settings.browser.headless.title": "Headless-Modus", - "settings.browser.headless.description": "Im Headless-Modus ausführen (kein sichtbares Browserfenster).", + "settings.browser.headless.description": + "Ohne sichtbares Browserfenster ausführen. Deaktivieren, um ein sichtbares Chrome-Fenster zu öffnen.", "settings.language.description": 'Wählen Sie die Sprache für die Kilo Code Oberfläche. „Auto" verwendet die VS Code Anzeigesprache.', @@ -884,6 +885,9 @@ export const dict = { "settings.experimental.codebaseSearch.title": "Codebase-Suche", "settings.experimental.codebaseSearch.description": "KI-gestützte Suche in natürlicher Sprache über die gesamte Codebasis aktivieren", + "settings.experimental.worldBrowser.title": "World-Browser", + "settings.experimental.worldBrowser.description": + "Das World-Browserwerkzeug aktivieren. Es läuft standardmäßig headless und gibt nach jeder visuellen Aktion ein Bild zurück. Deaktivieren, um Browserfunktionen vor dem Agenten zu verbergen.", "settings.experimental.imageGeneration.title": "Bildgenerierung", "settings.experimental.imageGeneration.description": "KI-Bildgenerierung aktivieren", "settings.experimental.imageGenerationModel.title": "Bildmodell", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/en.ts b/packages/kilo-vscode/webview-ui/src/i18n/en.ts index 5752f6b2a8c..e7751fbf645 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/en.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/en.ts @@ -793,14 +793,15 @@ export const dict = { "settings.agentBehaviour.subtab.skills": "Skills", "settings.browser.description": - "When enabled, the AI agent can interact with web pages — navigating, clicking, typing, and taking screenshots. A Chrome window will open so you can watch the agent work.", + "Drive a headless browser to navigate, click, type, and capture screenshots. The browser runs without a visible window by default, but you'll see an inline image of the page after every visual action.", "settings.browser.enable.title": "Enable Browser Automation", - "settings.browser.enable.description": "Register the Playwright MCP server with the CLI backend.", + "settings.browser.enable.description": "Enable the World tool for browser automation.", "settings.browser.systemChrome.title": "Use System Chrome", "settings.browser.systemChrome.description": - "Use your installed Chrome browser instead of a separate Chromium instance.", + "Use your installed Chrome browser instead of a separate Chromium instance. Falls back to Chromium when Chrome is not found.", "settings.browser.headless.title": "Headless Mode", - "settings.browser.headless.description": "Run in headless mode (no visible browser window).", + "settings.browser.headless.description": + "Run in headless mode (no visible browser window). Off to open a Chrome window you can watch.", "settings.language.description": 'Choose the language for the Kilo Code UI. "Auto" uses your VS Code display language.', @@ -842,6 +843,9 @@ export const dict = { "settings.experimental.batch.description": "Enable batching of multiple tool calls", "settings.experimental.codebaseSearch.title": "Codebase Search", "settings.experimental.codebaseSearch.description": "Enable AI-powered natural language search across your codebase", + "settings.experimental.worldBrowser.title": "World Browser", + "settings.experimental.worldBrowser.description": + "Enable the World browser tool. The browser runs headless by default and returns an inline image after every visual action. Disable to hide browser capabilities from the agent.", "settings.experimental.imageGeneration.title": "Image Generation", "settings.experimental.imageGeneration.description": "Enable AI image generation", "settings.experimental.imageGenerationModel.title": "Image Model", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/es.ts b/packages/kilo-vscode/webview-ui/src/i18n/es.ts index 2b7ed694b0b..47e4d6ec82b 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/es.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/es.ts @@ -827,14 +827,15 @@ export const dict = { "settings.agentBehaviour.subtab.skills": "Habilidades", "settings.browser.description": - "Cuando está activado, el agente de IA puede interactuar con páginas web: navegar, hacer clic, escribir y capturar pantallas. Se abrirá una ventana de Chrome para que puedas ver al agente trabajar.", + "Controla un navegador sin interfaz para navegar, hacer clic, escribir y capturar pantallas. El navegador se ejecuta sin una ventana visible de forma predeterminada, pero muestra una imagen de la página después de cada acción visual.", "settings.browser.enable.title": "Activar automatización del navegador", - "settings.browser.enable.description": "Registrar el servidor Playwright MCP con el backend CLI.", + "settings.browser.enable.description": "Activa la herramienta World para automatizar el navegador.", "settings.browser.systemChrome.title": "Usar Chrome del sistema", "settings.browser.systemChrome.description": - "Usar el navegador Chrome instalado en lugar de una instancia separada de Chromium.", + "Usa Chrome instalado en lugar de una instancia separada de Chromium. Si no se encuentra Chrome, usa Chromium.", "settings.browser.headless.title": "Modo headless", - "settings.browser.headless.description": "Ejecutar en modo headless (sin ventana de navegador visible).", + "settings.browser.headless.description": + "Ejecuta sin una ventana visible. Desactívalo para abrir una ventana de Chrome que puedas observar.", "settings.language.description": 'Elige el idioma de la interfaz de Kilo Code. "Auto" utiliza el idioma de visualización de VS Code.', @@ -874,6 +875,9 @@ export const dict = { "settings.experimental.codebaseSearch.title": "Búsqueda de código", "settings.experimental.codebaseSearch.description": "Habilitar búsqueda por lenguaje natural con IA en toda la base de código", + "settings.experimental.worldBrowser.title": "Navegador World", + "settings.experimental.worldBrowser.description": + "Activa la herramienta de navegador World. Se ejecuta sin interfaz de forma predeterminada y devuelve una imagen después de cada acción visual. Desactívala para ocultar las capacidades del navegador al agente.", "settings.experimental.imageGeneration.title": "Generación de imágenes", "settings.experimental.imageGeneration.description": "Habilitar generación de imágenes con IA", "settings.experimental.imageGenerationModel.title": "Modelo de imagen", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/fa.ts b/packages/kilo-vscode/webview-ui/src/i18n/fa.ts index 71b97a2d3ff..1e3aec83861 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/fa.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/fa.ts @@ -800,14 +800,15 @@ export const dict = { "settings.agentBehaviour.subtab.skills": "مهارت‌ها", "settings.browser.description": - "وقتی فعال است، عامل هوش مصنوعی می‌تواند با صفحات وب تعامل داشته باشد — پیمایش، کلیک، تایپ و گرفتن اسکرین‌شات. یک پنجره Chrome باز می‌شود تا بتوانید عملکرد عامل را مشاهده کنید.", + "یک مرورگر بدون رابط را برای پیمایش، کلیک، تایپ و گرفتن اسکرین‌شات هدایت کنید. مرورگر به‌طور پیش‌فرض بدون پنجرهٔ قابل مشاهده اجرا می‌شود، اما پس از هر اقدام تصویری یک تصویر درون‌خطی از صفحه خواهید دید.", "settings.browser.enable.title": "فعال‌سازی اتوماسیون مرورگر", - "settings.browser.enable.description": "سرور Playwright MCP را با بک‌اند CLI ثبت کنید.", + "settings.browser.enable.description": "ابزار World را برای اتوماسیون مرورگر فعال کنید.", "settings.browser.systemChrome.title": "استفاده از Chrome سیستم", "settings.browser.systemChrome.description": - "به جای یک نمونه Chromium جداگانه، از مرورگر Chrome نصب‌شده شما استفاده کنید.", + "به جای یک نمونه Chromium جداگانه، از مرورگر Chrome نصب‌شدهٔ شما استفاده کنید. اگر Chrome پیدا نشود، از Chromium استفاده می‌شود.", "settings.browser.headless.title": "حالت Headless", - "settings.browser.headless.description": "در حالت headless اجرا شود (بدون پنجره مرورگر قابل مشاهده).", + "settings.browser.headless.description": + "در حالت بدون رابط اجرا شود (بدون پنجرهٔ مرورگر قابل مشاهده). برای باز کردن پنجرهٔ Chrome قابل مشاهده، آن را خاموش کنید.", "settings.language.description": "زبان رابط کاربری Kilo Code را انتخاب کنید. «Auto» از زبان نمایشی VS Code شما استفاده می‌کند.", @@ -849,6 +850,9 @@ export const dict = { "settings.experimental.codebaseSearch.title": "جستجوی پایگاه کد", "settings.experimental.codebaseSearch.description": "فعال‌سازی جستجوی زبان طبیعی مبتنی بر هوش مصنوعی در سراسر پایگاه کد", + "settings.experimental.worldBrowser.title": "مرورگر World", + "settings.experimental.worldBrowser.description": + "ابزار مرورگر World را فعال کنید. مرورگر به‌طور پیش‌فرض بدون رابط اجرا می‌شود و پس از هر اقدام تصویری یک تصویر درون‌خطی برمی‌گرداند. برای پنهان کردن قابلیت‌های مرورگر از عامل، آن را غیرفعال کنید.", "settings.experimental.imageGeneration.title": "تولید تصویر", "settings.experimental.imageGeneration.description": "فعال‌سازی تولید تصویر با هوش مصنوعی", "settings.experimental.imageGenerationModel.title": "مدل تصویر", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/fr.ts b/packages/kilo-vscode/webview-ui/src/i18n/fr.ts index 3fd9956d9de..b8f93d9cfab 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/fr.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/fr.ts @@ -836,14 +836,15 @@ export const dict = { "settings.agentBehaviour.subtab.skills": "Compétences", "settings.browser.description": - "Lorsqu'il est activé, l'agent IA peut interagir avec les pages web — naviguer, cliquer, saisir du texte et prendre des captures d'écran. Une fenêtre Chrome s'ouvrira pour que vous puissiez observer l'agent travailler.", + "Pilotez un navigateur headless pour naviguer, cliquer, saisir du texte et prendre des captures d'écran. Par défaut, le navigateur fonctionne sans fenêtre visible, mais affiche une image intégrée après chaque action visuelle.", "settings.browser.enable.title": "Activer l'automatisation du navigateur", - "settings.browser.enable.description": "Enregistrer le serveur Playwright MCP auprès du backend CLI.", + "settings.browser.enable.description": "Activer l'outil World pour automatiser le navigateur.", "settings.browser.systemChrome.title": "Utiliser le Chrome système", "settings.browser.systemChrome.description": - "Utiliser votre navigateur Chrome installé au lieu d'une instance Chromium séparée.", + "Utiliser le navigateur Chrome installé au lieu d'une instance Chromium séparée. Utilise Chromium si Chrome est introuvable.", "settings.browser.headless.title": "Mode headless", - "settings.browser.headless.description": "Exécuter en mode headless (sans fenêtre de navigateur visible).", + "settings.browser.headless.description": + "Exécuter sans fenêtre visible. Désactivez cette option pour ouvrir une fenêtre Chrome observable.", "settings.language.description": "Choisissez la langue de l'interface de Kilo Code. \"Auto\" utilise la langue d'affichage de VS Code.", @@ -885,6 +886,9 @@ export const dict = { "settings.experimental.codebaseSearch.title": "Recherche de code", "settings.experimental.codebaseSearch.description": "Activer la recherche en langage naturel par IA dans toute la base de code", + "settings.experimental.worldBrowser.title": "Navigateur World", + "settings.experimental.worldBrowser.description": + "Activer l'outil de navigation World. Il fonctionne en mode headless par défaut et renvoie une image après chaque action visuelle. Désactivez-le pour masquer les fonctions de navigation à l'agent.", "settings.experimental.imageGeneration.title": "Génération d'images", "settings.experimental.imageGeneration.description": "Activer la génération d'images par IA", "settings.experimental.imageGenerationModel.title": "Modèle d'image", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/it.ts b/packages/kilo-vscode/webview-ui/src/i18n/it.ts index b8ec52156f4..bb590a2f901 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/it.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/it.ts @@ -669,14 +669,15 @@ export const dict = { "settings.agentBehaviour.subtab.workflows": "Workflow", "settings.agentBehaviour.subtab.skills": "Skill", "settings.browser.description": - "Quando è abilitato, l'agente AI può interagire con pagine web: navigare, fare clic, scrivere e acquisire screenshot. Si aprirà una finestra Chrome così puoi osservare il lavoro dell'agente.", + "Controlla un browser headless per navigare, fare clic, scrivere e acquisire screenshot. Per impostazione predefinita il browser viene eseguito senza una finestra visibile, ma mostra un'immagine della pagina dopo ogni azione visiva.", "settings.browser.enable.title": "Abilita automazione browser", - "settings.browser.enable.description": "Registra il server MCP Playwright con il backend CLI.", + "settings.browser.enable.description": "Abilita lo strumento World per l'automazione del browser.", "settings.browser.systemChrome.title": "Usa Chrome di sistema", "settings.browser.systemChrome.description": - "Usa il browser Chrome installato invece di un'istanza Chromium separata.", + "Usa Chrome installato invece di un'istanza Chromium separata. Se Chrome non viene trovato, usa Chromium.", "settings.browser.headless.title": "Modalità headless", - "settings.browser.headless.description": "Esegui in modalità headless (senza finestra browser visibile).", + "settings.browser.headless.description": + "Esegui senza una finestra visibile. Disattiva per aprire una finestra Chrome osservabile.", "settings.language.description": 'Scegli la lingua dell\'interfaccia Kilo Code. "Auto" usa la lingua di VS Code.', "settings.language.auto": "Auto (lingua VS Code)", "settings.language.current": "Corrente:", @@ -716,6 +717,9 @@ export const dict = { "Abilita l'indicizzazione semantica del codebase e il tool semantic_search. Richiede configurazione indicizzazione.", "settings.experimental.codebaseSearch.title": "Ricerca codebase", "settings.experimental.codebaseSearch.description": "Abilita ricerca in linguaggio naturale con AI nel codebase", + "settings.experimental.worldBrowser.title": "Browser World", + "settings.experimental.worldBrowser.description": + "Abilita lo strumento browser World. Funziona in modalità headless per impostazione predefinita e restituisce un'immagine dopo ogni azione visiva. Disattiva per nascondere le funzionalità del browser all'agente.", "settings.experimental.imageGeneration.title": "Generazione di immagini", "settings.experimental.imageGeneration.description": "Abilita la generazione di immagini con AI", "settings.experimental.imageGenerationModel.title": "Modello di immagine", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ja.ts b/packages/kilo-vscode/webview-ui/src/i18n/ja.ts index fa1eda6f6b0..001c686790b 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ja.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ja.ts @@ -813,14 +813,15 @@ export const dict = { "settings.agentBehaviour.subtab.skills": "スキル", "settings.browser.description": - "有効にすると、AIエージェントがWebページを操作できるようになります — ナビゲーション、クリック、入力、スクリーンショットの撮影。Chromeウィンドウが開き、エージェントの作業を確認できます。", + "ヘッドレスブラウザを操作して、移動、クリック、入力、スクリーンショットの撮影を行います。既定では表示ウィンドウなしで実行されますが、視覚的な操作のたびにページ画像が表示されます。", "settings.browser.enable.title": "ブラウザ自動化を有効にする", - "settings.browser.enable.description": "Playwright MCPサーバーをCLIバックエンドに登録します。", + "settings.browser.enable.description": "ブラウザ自動化用のWorldツールを有効にします。", "settings.browser.systemChrome.title": "システムChromeを使用", "settings.browser.systemChrome.description": - "別のChromiumインスタンスの代わりに、インストール済みのChromeブラウザを使用します。", + "別のChromiumインスタンスではなく、インストール済みのChromeを使用します。Chromeが見つからない場合はChromiumを使用します。", "settings.browser.headless.title": "ヘッドレスモード", - "settings.browser.headless.description": "ヘッドレスモードで実行します(ブラウザウィンドウは表示されません)。", + "settings.browser.headless.description": + "表示ウィンドウなしで実行します。オフにすると、確認できるChromeウィンドウが開きます。", "settings.language.description": "Kilo Code UIの言語を選択します。「自動」はVS Codeの表示言語を使用します。", "settings.language.auto": "自動(VS Code言語)", @@ -857,6 +858,9 @@ export const dict = { "settings.experimental.batch.description": "複数のツール呼び出しのバッチ処理を有効にする", "settings.experimental.codebaseSearch.title": "コードベース検索", "settings.experimental.codebaseSearch.description": "コードベース全体でAIによる自然言語検索を有効にする", + "settings.experimental.worldBrowser.title": "Worldブラウザ", + "settings.experimental.worldBrowser.description": + "Worldブラウザツールを有効にします。既定ではヘッドレスで実行され、視覚的な操作のたびに画像を返します。無効にするとエージェントからブラウザ機能を隠します。", "settings.experimental.imageGeneration.title": "画像生成", "settings.experimental.imageGeneration.description": "AI画像生成を有効にする", "settings.experimental.imageGenerationModel.title": "画像モデル", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ko.ts b/packages/kilo-vscode/webview-ui/src/i18n/ko.ts index 360ed69cb00..3f5b6d4052f 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ko.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ko.ts @@ -811,13 +811,15 @@ export const dict = { "settings.agentBehaviour.subtab.skills": "스킬", "settings.browser.description": - "활성화하면 AI 에이전트가 웹 페이지와 상호작용할 수 있습니다 — 탐색, 클릭, 입력, 스크린샷 촬영. Chrome 창이 열려 에이전트의 작업을 확인할 수 있습니다.", + "헤드리스 브라우저를 제어하여 탐색, 클릭, 입력 및 스크린샷 촬영을 수행합니다. 기본적으로 표시되는 창 없이 실행되지만 각 시각적 작업 후 페이지 이미지를 표시합니다.", "settings.browser.enable.title": "브라우저 자동화 활성화", - "settings.browser.enable.description": "Playwright MCP 서버를 CLI 백엔드에 등록합니다.", + "settings.browser.enable.description": "브라우저 자동화를 위한 World 도구를 활성화합니다.", "settings.browser.systemChrome.title": "시스템 Chrome 사용", - "settings.browser.systemChrome.description": "별도의 Chromium 인스턴스 대신 설치된 Chrome 브라우저를 사용합니다.", + "settings.browser.systemChrome.description": + "별도의 Chromium 대신 설치된 Chrome을 사용합니다. Chrome을 찾지 못하면 Chromium을 사용합니다.", "settings.browser.headless.title": "헤드리스 모드", - "settings.browser.headless.description": "헤드리스 모드로 실행합니다 (브라우저 창이 표시되지 않음).", + "settings.browser.headless.description": + "표시되는 브라우저 창 없이 실행합니다. 끄면 확인할 수 있는 Chrome 창이 열립니다.", "settings.language.description": 'Kilo Code UI의 언어를 선택하세요. "자동"은 VS Code 표시 언어를 사용합니다.', "settings.language.auto": "자동 (VS Code 언어)", @@ -854,6 +856,9 @@ export const dict = { "settings.experimental.batch.description": "여러 도구 호출의 배치 처리 활성화", "settings.experimental.codebaseSearch.title": "코드베이스 검색", "settings.experimental.codebaseSearch.description": "코드베이스 전체에서 AI 기반 자연어 검색 활성화", + "settings.experimental.worldBrowser.title": "World 브라우저", + "settings.experimental.worldBrowser.description": + "World 브라우저 도구를 활성화합니다. 기본적으로 헤드리스로 실행되며 각 시각적 작업 후 이미지를 반환합니다. 비활성화하면 에이전트에서 브라우저 기능을 숨깁니다.", "settings.experimental.imageGeneration.title": "이미지 생성", "settings.experimental.imageGeneration.description": "AI 이미지 생성 활성화", "settings.experimental.imageGenerationModel.title": "이미지 모델", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/nl.ts b/packages/kilo-vscode/webview-ui/src/i18n/nl.ts index 64f70685b4c..43b67fa6a22 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/nl.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/nl.ts @@ -813,14 +813,15 @@ export const dict = { "settings.agentBehaviour.subtab.skills": "Skills", "settings.browser.description": - "Indien ingeschakeld kan de AI agent interactie hebben met webpagina's — navigeren, klikken, typen en screenshots maken. Er wordt een Chrome-venster geopend zodat je de agent aan het werk kunt zien.", + "Bestuur een headless browser om te navigeren, klikken, typen en screenshots te maken. De browser draait standaard zonder zichtbaar venster, maar toont na elke visuele actie een afbeelding van de pagina.", "settings.browser.enable.title": "Browserautomatisering inschakelen", - "settings.browser.enable.description": "Registreer de Playwright MCP-server bij de CLI backend.", + "settings.browser.enable.description": "Schakel de World-tool voor browserautomatisering in.", "settings.browser.systemChrome.title": "Gebruik Systeem Chrome", "settings.browser.systemChrome.description": - "Gebruik je geïnstalleerde Chrome-browser in plaats van een aparte Chromium-instantie.", + "Gebruik geïnstalleerde Chrome in plaats van een aparte Chromium-instantie. Valt terug op Chromium als Chrome niet wordt gevonden.", "settings.browser.headless.title": "Headless Modus", - "settings.browser.headless.description": "Draai in headless modus (geen zichtbaar browservenster).", + "settings.browser.headless.description": + "Draai zonder zichtbaar browservenster. Schakel uit om een zichtbaar Chrome-venster te openen.", "settings.language.description": 'Kies de taal voor de Kilo Code UI. "Auto" gebruikt je VS Code schermtaal.', "settings.language.auto": "Auto (VS Code taal)", @@ -864,6 +865,9 @@ export const dict = { "settings.experimental.codebaseSearch.title": "Codebase Zoeken", "settings.experimental.codebaseSearch.description": "Schakel AI-aangedreven zoeken in natuurlijke taal door je codebase in", + "settings.experimental.worldBrowser.title": "World-browser", + "settings.experimental.worldBrowser.description": + "Schakel de World-browsertool in. Deze draait standaard headless en geeft na elke visuele actie een afbeelding terug. Schakel uit om browsermogelijkheden voor de agent te verbergen.", "settings.experimental.imageGeneration.title": "Afbeeldingsgeneratie", "settings.experimental.imageGeneration.description": "AI-afbeeldingsgeneratie inschakelen", "settings.experimental.imageGenerationModel.title": "Afbeeldingsmodel", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/no.ts b/packages/kilo-vscode/webview-ui/src/i18n/no.ts index 103de2edff6..0935ae5c7b4 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/no.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/no.ts @@ -780,14 +780,15 @@ export const dict = { "settings.agentBehaviour.subtab.skills": "Ferdigheter", "settings.browser.description": - "Når aktivert kan AI-agenten samhandle med nettsider — navigere, klikke, skrive og ta skjermbilder. Et Chrome-vindu åpnes slik at du kan se agenten jobbe.", + "Styr en headless-nettleser for å navigere, klikke, skrive og ta skjermbilder. Nettleseren kjører som standard uten et synlig vindu, men viser et bilde av siden etter hver visuelle handling.", "settings.browser.enable.title": "Aktiver nettleserautomatisering", - "settings.browser.enable.description": "Registrer Playwright MCP-serveren med CLI-backend.", + "settings.browser.enable.description": "Aktiver World-verktøyet for nettleserautomatisering.", "settings.browser.systemChrome.title": "Bruk system-Chrome", "settings.browser.systemChrome.description": - "Bruk den installerte Chrome-nettleseren i stedet for en separat Chromium-instans.", + "Bruk installert Chrome i stedet for en separat Chromium-instans. Bruker Chromium hvis Chrome ikke finnes.", "settings.browser.headless.title": "Headless-modus", - "settings.browser.headless.description": "Kjør i headless-modus (uten synlig nettleservindu).", + "settings.browser.headless.description": + "Kjør uten et synlig nettleservindu. Slå av for å åpne et Chrome-vindu du kan følge.", "settings.language.description": 'Velg språket for Kilo Code-grensesnittet. "Auto" bruker VS Codes visningsspråk.', "settings.language.auto": "Auto (VS Code-språk)", @@ -825,6 +826,9 @@ export const dict = { "settings.experimental.batch.description": "Aktiver batchbehandling av verktøykall", "settings.experimental.codebaseSearch.title": "Kodesøk", "settings.experimental.codebaseSearch.description": "Aktiver AI-drevet naturlig språksøk på tvers av kodebasen", + "settings.experimental.worldBrowser.title": "World-nettleser", + "settings.experimental.worldBrowser.description": + "Aktiver World-nettleserverktøyet. Det kjører headless som standard og returnerer et bilde etter hver visuelle handling. Slå av for å skjule nettleserfunksjoner for agenten.", "settings.experimental.imageGeneration.title": "Bildegenerering", "settings.experimental.imageGeneration.description": "Aktiver AI-bildegenerering", "settings.experimental.imageGenerationModel.title": "Bildemodell", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/pl.ts b/packages/kilo-vscode/webview-ui/src/i18n/pl.ts index 18830cde9be..9b32d440b5e 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/pl.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/pl.ts @@ -777,14 +777,15 @@ export const dict = { "settings.agentBehaviour.subtab.skills": "Umiejętności", "settings.browser.description": - "Po włączeniu agent AI może wchodzić w interakcję ze stronami internetowymi — nawigować, klikać, pisać i robić zrzuty ekranu. Otworzy się okno Chrome, abyś mógł obserwować pracę agenta.", + "Steruj przeglądarką bez interfejsu, aby nawigować, klikać, pisać i robić zrzuty ekranu. Domyślnie przeglądarka działa bez widocznego okna, ale po każdej czynności wizualnej pokazuje obraz strony.", "settings.browser.enable.title": "Włącz automatyzację przeglądarki", - "settings.browser.enable.description": "Zarejestruj serwer Playwright MCP w backendzie CLI.", + "settings.browser.enable.description": "Włącz narzędzie World do automatyzacji przeglądarki.", "settings.browser.systemChrome.title": "Użyj systemowego Chrome", "settings.browser.systemChrome.description": - "Użyj zainstalowanej przeglądarki Chrome zamiast oddzielnej instancji Chromium.", + "Użyj zainstalowanej przeglądarki Chrome zamiast oddzielnego Chromium. Jeśli Chrome nie zostanie znaleziony, użyty zostanie Chromium.", "settings.browser.headless.title": "Tryb headless", - "settings.browser.headless.description": "Uruchom w trybie headless (bez widocznego okna przeglądarki).", + "settings.browser.headless.description": + "Uruchom bez widocznego okna. Wyłącz, aby otworzyć okno Chrome, które można obserwować.", "settings.language.description": 'Wybierz język interfejsu Kilo Code. „Auto" używa języka wyświetlania VS Code.', "settings.language.auto": "Auto (język VS Code)", @@ -821,6 +822,9 @@ export const dict = { "settings.experimental.batch.description": "Włącz przetwarzanie wsadowe wywołań narzędzi", "settings.experimental.codebaseSearch.title": "Wyszukiwanie kodu", "settings.experimental.codebaseSearch.description": "Włącz wyszukiwanie w języku naturalnym z AI w całej bazie kodu", + "settings.experimental.worldBrowser.title": "Przeglądarka World", + "settings.experimental.worldBrowser.description": + "Włącz narzędzie przeglądarki World. Domyślnie działa bez interfejsu i zwraca obraz po każdej czynności wizualnej. Wyłącz, aby ukryć możliwości przeglądarki przed agentem.", "settings.experimental.imageGeneration.title": "Generowanie obrazów", "settings.experimental.imageGeneration.description": "Włącz generowanie obrazów przez AI", "settings.experimental.imageGenerationModel.title": "Model obrazu", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ru.ts b/packages/kilo-vscode/webview-ui/src/i18n/ru.ts index 5eca69def5d..05968a4931d 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ru.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ru.ts @@ -817,14 +817,15 @@ export const dict = { "settings.agentBehaviour.subtab.skills": "Навыки", "settings.browser.description": - "При включении ИИ-агент может взаимодействовать с веб-страницами — переходить по ссылкам, нажимать, вводить текст и делать скриншоты. Откроется окно Chrome, чтобы вы могли наблюдать за работой агента.", + "Управляйте браузером в фоновом режиме: переходите по страницам, нажимайте, вводите текст и делайте снимки экрана. По умолчанию браузер работает без видимого окна, но показывает изображение страницы после каждого визуального действия.", "settings.browser.enable.title": "Включить автоматизацию браузера", - "settings.browser.enable.description": "Зарегистрировать сервер Playwright MCP в CLI-бэкенде.", + "settings.browser.enable.description": "Включить инструмент World для автоматизации браузера.", "settings.browser.systemChrome.title": "Использовать системный Chrome", "settings.browser.systemChrome.description": - "Использовать установленный браузер Chrome вместо отдельного экземпляра Chromium.", + "Использовать установленный Chrome вместо отдельного Chromium. Если Chrome не найден, используется Chromium.", "settings.browser.headless.title": "Безголовый режим", - "settings.browser.headless.description": "Запуск в безголовом режиме (без видимого окна браузера).", + "settings.browser.headless.description": + "Запускать без видимого окна. Отключите, чтобы открыть наблюдаемое окно Chrome.", "settings.language.description": "Выберите язык интерфейса Kilo Code. «Авто» использует язык отображения VS Code.", "settings.language.auto": "Авто (язык VS Code)", @@ -861,6 +862,9 @@ export const dict = { "settings.experimental.batch.description": "Включить пакетную обработку вызовов инструментов", "settings.experimental.codebaseSearch.title": "Поиск по коду", "settings.experimental.codebaseSearch.description": "Включить поиск на естественном языке с ИИ по всей кодовой базе", + "settings.experimental.worldBrowser.title": "Браузер World", + "settings.experimental.worldBrowser.description": + "Включить браузерный инструмент World. По умолчанию он работает без окна и возвращает изображение после каждого визуального действия. Отключите, чтобы скрыть возможности браузера от агента.", "settings.experimental.imageGeneration.title": "Генерация изображений", "settings.experimental.imageGeneration.description": "Включить генерацию изображений с помощью ИИ", "settings.experimental.imageGenerationModel.title": "Модель изображений", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/th.ts b/packages/kilo-vscode/webview-ui/src/i18n/th.ts index c4dbc68bcf5..bc6190e0d76 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/th.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/th.ts @@ -807,13 +807,15 @@ export const dict = { "settings.agentBehaviour.subtab.skills": "ทักษะ", "settings.browser.description": - "เมื่อเปิดใช้งาน ตัวแทน AI สามารถโต้ตอบกับหน้าเว็บได้ — นำทาง คลิก พิมพ์ และจับภาพหน้าจอ หน้าต่าง Chrome จะเปิดขึ้นเพื่อให้คุณดูการทำงานของตัวแทน", + "ควบคุมเบราว์เซอร์แบบไม่มีหน้าต่างเพื่อนำทาง คลิก พิมพ์ และจับภาพหน้าจอ โดยค่าเริ่มต้นเบราว์เซอร์จะทำงานโดยไม่มีหน้าต่างที่มองเห็น แต่จะแสดงภาพของหน้าหลังการทำงานที่มองเห็นแต่ละครั้ง", "settings.browser.enable.title": "เปิดใช้งานการทำงานอัตโนมัติของเบราว์เซอร์", - "settings.browser.enable.description": "ลงทะเบียนเซิร์ฟเวอร์ Playwright MCP กับ CLI backend", + "settings.browser.enable.description": "เปิดใช้เครื่องมือ World สำหรับระบบอัตโนมัติของเบราว์เซอร์", "settings.browser.systemChrome.title": "ใช้ Chrome ของระบบ", - "settings.browser.systemChrome.description": "ใช้เบราว์เซอร์ Chrome ที่ติดตั้งแทนอินสแตนซ์ Chromium แยกต่างหาก", + "settings.browser.systemChrome.description": + "ใช้ Chrome ที่ติดตั้งแทน Chromium แยกต่างหาก และใช้ Chromium หากไม่พบ Chrome", "settings.browser.headless.title": "โหมด Headless", - "settings.browser.headless.description": "ทำงานในโหมด headless (ไม่มีหน้าต่างเบราว์เซอร์ที่มองเห็นได้)", + "settings.browser.headless.description": + "ทำงานโดยไม่มีหน้าต่างเบราว์เซอร์ที่มองเห็น ปิดตัวเลือกนี้เพื่อเปิดหน้าต่าง Chrome ที่คุณดูได้", "settings.language.description": 'เลือกภาษาสำหรับ UI ของ Kilo Code "อัตโนมัติ" จะใช้ภาษาการแสดงผลของ VS Code', "settings.language.auto": "อัตโนมัติ (ภาษา VS Code)", @@ -850,6 +852,9 @@ export const dict = { "settings.experimental.batch.description": "เปิดใช้งานการประมวลผลแบทช์ของการเรียกเครื่องมือ", "settings.experimental.codebaseSearch.title": "ค้นหาโค้ดเบส", "settings.experimental.codebaseSearch.description": "เปิดใช้งานการค้นหาด้วยภาษาธรรมชาติโดย AI ทั่วทั้งโค้ดเบส", + "settings.experimental.worldBrowser.title": "เบราว์เซอร์ World", + "settings.experimental.worldBrowser.description": + "เปิดใช้เครื่องมือเบราว์เซอร์ World ซึ่งทำงานแบบไม่มีหน้าต่างโดยค่าเริ่มต้นและส่งคืนภาพหลังการทำงานที่มองเห็นแต่ละครั้ง ปิดเพื่อซ่อนความสามารถของเบราว์เซอร์จากตัวแทน", "settings.experimental.imageGeneration.title": "การสร้างภาพ", "settings.experimental.imageGeneration.description": "เปิดใช้งานการสร้างภาพด้วย AI", "settings.experimental.imageGenerationModel.title": "โมเดลภาพ", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/tr.ts b/packages/kilo-vscode/webview-ui/src/i18n/tr.ts index 0761b27b94a..16c51718d31 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/tr.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/tr.ts @@ -806,13 +806,15 @@ export const dict = { "settings.agentBehaviour.subtab.skills": "Beceriler", "settings.browser.description": - "Etkinleştirildiğinde, yapay zeka ajanı web sayfalarıyla etkileşime girebilir — gezinme, tıklama, yazma ve ekran görüntüsü alma. Ajanın çalışmasını izleyebilmeniz için bir Chrome penceresi açılacak.", + "Gezinmek, tıklamak, yazmak ve ekran görüntüsü almak için başsız bir tarayıcıyı kontrol edin. Tarayıcı varsayılan olarak görünür bir pencere olmadan çalışır, ancak her görsel işlemden sonra sayfanın bir görüntüsünü gösterir.", "settings.browser.enable.title": "Tarayıcı Otomasyonunu Etkinleştir", - "settings.browser.enable.description": "Playwright MCP sunucusunu CLI arka ucuyla kaydet.", + "settings.browser.enable.description": "Tarayıcı otomasyonu için World aracını etkinleştirin.", "settings.browser.systemChrome.title": "Sistem Chrome'unu Kullan", - "settings.browser.systemChrome.description": "Ayrı bir Chromium örneği yerine yüklü Chrome tarayıcınızı kullanın.", + "settings.browser.systemChrome.description": + "Ayrı bir Chromium yerine yüklü Chrome'u kullanın. Chrome bulunamazsa Chromium kullanılır.", "settings.browser.headless.title": "Başsız Mod", - "settings.browser.headless.description": "Başsız modda çalıştır (görünür tarayıcı penceresi yok).", + "settings.browser.headless.description": + "Görünür tarayıcı penceresi olmadan çalıştırın. İzleyebileceğiniz bir Chrome penceresi açmak için kapatın.", "settings.language.description": '"Otomatik" VS Code görüntüleme dilinizi kullanır. Kilo Code arayüzü için dil seçin.', @@ -854,6 +856,9 @@ export const dict = { "settings.experimental.codebaseSearch.title": "Kod Tabanı Araması", "settings.experimental.codebaseSearch.description": "Kod tabanınız genelinde yapay zeka destekli doğal dil aramasını etkinleştir", + "settings.experimental.worldBrowser.title": "World Tarayıcısı", + "settings.experimental.worldBrowser.description": + "World tarayıcı aracını etkinleştirin. Varsayılan olarak başsız çalışır ve her görsel işlemden sonra bir görüntü döndürür. Tarayıcı özelliklerini ajandan gizlemek için devre dışı bırakın.", "settings.experimental.imageGeneration.title": "Görüntü oluşturma", "settings.experimental.imageGeneration.description": "AI görüntü oluşturmayı etkinleştir", "settings.experimental.imageGenerationModel.title": "Görüntü modeli", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/uk.ts b/packages/kilo-vscode/webview-ui/src/i18n/uk.ts index b4b8c529d24..d6fcf722489 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/uk.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/uk.ts @@ -807,14 +807,15 @@ export const dict = { "settings.agentBehaviour.subtab.skills": "Навички", "settings.browser.description": - "Після увімкнення агент ШІ може взаємодіяти з веб-сторінками — навігація, натискання, введення тексту та знімки екрана. Відкриється вікно Chrome, щоб ви могли стежити за роботою агента.", + "Керуйте браузером у фоновому режимі: переходьте сторінками, натискайте, вводьте текст і робіть знімки екрана. За замовчуванням браузер працює без видимого вікна, але показує зображення сторінки після кожної візуальної дії.", "settings.browser.enable.title": "Увімкнути автоматизацію браузера", - "settings.browser.enable.description": "Зареєструвати MCP-сервер Playwright з CLI-бекендом.", + "settings.browser.enable.description": "Увімкнути інструмент World для автоматизації браузера.", "settings.browser.systemChrome.title": "Використовувати системний Chrome", "settings.browser.systemChrome.description": - "Використовувати встановлений браузер Chrome замість окремого екземпляра Chromium.", + "Використовувати встановлений Chrome замість окремого Chromium. Якщо Chrome не знайдено, використовується Chromium.", "settings.browser.headless.title": "Безголовий режим", - "settings.browser.headless.description": "Запускати в безголовому режимі (без видимого вікна браузера).", + "settings.browser.headless.description": + "Запускати без видимого вікна. Вимкніть, щоб відкрити вікно Chrome, за яким можна спостерігати.", "settings.language.description": '"Автоматично" використовує мову відображення VS Code. Виберіть мову для інтерфейсу Kilo Code.', @@ -856,6 +857,9 @@ export const dict = { "settings.experimental.codebaseSearch.title": "Пошук по кодовій базі", "settings.experimental.codebaseSearch.description": "Увімкнути пошук природною мовою на основі ШІ по всій кодовій базі", + "settings.experimental.worldBrowser.title": "Браузер World", + "settings.experimental.worldBrowser.description": + "Увімкнути браузерний інструмент World. За замовчуванням він працює без вікна та повертає зображення після кожної візуальної дії. Вимкніть, щоб приховати можливості браузера від агента.", "settings.experimental.imageGeneration.title": "Генерація зображень", "settings.experimental.imageGeneration.description": "Увімкнути генерацію зображень за допомогою ШІ", "settings.experimental.imageGenerationModel.title": "Модель зображень", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/zh.ts b/packages/kilo-vscode/webview-ui/src/i18n/zh.ts index 041c72d116b..e5c6e299934 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/zh.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/zh.ts @@ -787,13 +787,14 @@ export const dict = { "settings.agentBehaviour.subtab.skills": "技能", "settings.browser.description": - "启用后,AI 代理可以与网页交互 — 导航、点击、输入和截取屏幕截图。Chrome 窗口将打开,以便您观察代理的工作。", + "控制无头浏览器进行导航、点击、输入和截图。浏览器默认在没有可见窗口的情况下运行,但会在每次可视操作后显示页面图像。", "settings.browser.enable.title": "启用浏览器自动化", - "settings.browser.enable.description": "将 Playwright MCP 服务器注册到 CLI 后端。", + "settings.browser.enable.description": "启用用于浏览器自动化的 World 工具。", "settings.browser.systemChrome.title": "使用系统 Chrome", - "settings.browser.systemChrome.description": "使用已安装的 Chrome 浏览器,而不是单独的 Chromium 实例。", + "settings.browser.systemChrome.description": + "使用已安装的 Chrome,而不是单独的 Chromium。如果找不到 Chrome,则使用 Chromium。", "settings.browser.headless.title": "无头模式", - "settings.browser.headless.description": "以无头模式运行(无可见浏览器窗口)。", + "settings.browser.headless.description": "在没有可见浏览器窗口的情况下运行。关闭后可打开便于观察的 Chrome 窗口。", "settings.language.description": '"自动"将使用 VS Code 的显示语言。选择 Kilo Code 界面的语言。', "settings.language.auto": "自动(VS Code 语言)", @@ -829,6 +830,9 @@ export const dict = { "settings.experimental.batch.description": "启用多个工具调用的批处理", "settings.experimental.codebaseSearch.title": "代码库搜索", "settings.experimental.codebaseSearch.description": "启用 AI 驱动的自然语言代码库搜索", + "settings.experimental.worldBrowser.title": "World 浏览器", + "settings.experimental.worldBrowser.description": + "启用 World 浏览器工具。它默认以无头模式运行,并在每次可视操作后返回图像。禁用后可对代理隐藏浏览器功能。", "settings.experimental.imageGeneration.title": "图像生成", "settings.experimental.imageGeneration.description": "启用 AI 图像生成", "settings.experimental.imageGenerationModel.title": "图像模型", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/zht.ts b/packages/kilo-vscode/webview-ui/src/i18n/zht.ts index 52ecca5fa9e..6a41493faae 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/zht.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/zht.ts @@ -747,13 +747,14 @@ export const dict = { "settings.agentBehaviour.subtab.skills": "Skills", "settings.browser.description": - "啟用後,AI Agent 可以與網頁互動,可導覽、點選、輸入並擷取螢幕截圖。Chrome 視窗將開啟,方便觀察 Agent 的工作。", + "控制無頭瀏覽器進行導覽、點選、輸入和擷取螢幕截圖。瀏覽器預設在沒有可見視窗的情況下執行,但會在每次視覺操作後顯示頁面圖像。", "settings.browser.enable.title": "啟用瀏覽器自動化", - "settings.browser.enable.description": "將 Playwright MCP 伺服器註冊到 CLI 後端。", + "settings.browser.enable.description": "啟用用於瀏覽器自動化的 World 工具。", "settings.browser.systemChrome.title": "使用系統 Chrome", - "settings.browser.systemChrome.description": "使用已安裝的 Chrome 瀏覽器,而非獨立的 Chromium 實例。", + "settings.browser.systemChrome.description": + "使用已安裝的 Chrome,而非獨立的 Chromium。找不到 Chrome 時會使用 Chromium。", "settings.browser.headless.title": "無頭模式", - "settings.browser.headless.description": "以無頭模式執行(無可見瀏覽器視窗)。", + "settings.browser.headless.description": "在沒有可見瀏覽器視窗的情況下執行。關閉後可開啟方便觀察的 Chrome 視窗。", "settings.language.description": "選擇 Kilo Code 介面的語言。「自動」使用 VS Code 的顯示語言。", "settings.language.auto": "自動(VS Code 語言)", @@ -789,6 +790,9 @@ export const dict = { "settings.experimental.batch.description": "啟用多個工具呼叫的批次處理", "settings.experimental.codebaseSearch.title": "程式碼庫搜尋", "settings.experimental.codebaseSearch.description": "啟用 AI 驅動的自然語言程式碼庫搜尋", + "settings.experimental.worldBrowser.title": "World 瀏覽器", + "settings.experimental.worldBrowser.description": + "啟用 World 瀏覽器工具。它預設以無頭模式執行,並在每次視覺操作後傳回圖像。停用後可對 Agent 隱藏瀏覽器功能。", "settings.experimental.imageGeneration.title": "圖像生成", "settings.experimental.imageGeneration.description": "啟用 AI 圖像生成", "settings.experimental.imageGenerationModel.title": "圖像模型", diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/config.ts b/packages/kilo-vscode/webview-ui/src/types/messages/config.ts index 84a6e9ff5b9..2f4413261c7 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages/config.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages/config.ts @@ -61,6 +61,7 @@ export interface ExperimentalConfig { mcp_timeout?: number swe_pruner?: boolean swe_pruner_model?: string + world_browser?: boolean } export interface SandboxConfig { @@ -127,15 +128,21 @@ export type KiloEmbeddingModelCatalog = { export type IndexingStatus = SdkIndexingStatus -export interface BrowserSettings { - enabled: boolean - useSystemChrome: boolean - headless: boolean -} - export type TerminalCommandDisplay = "expanded" | "collapsed" export type CodeEditDisplay = "expanded" | "collapsed" +export interface WorldConfig { + browser?: { + headless?: boolean + anti_detect?: boolean + timeout_ms?: number + viewport?: { width: number; height: number } + executable_path?: string + use_system_chrome?: boolean + args?: string[] + } +} + export interface Config { permission?: PermissionConfig model?: string | null @@ -168,6 +175,7 @@ export interface Config { web_search?: boolean auto_collapse_reasoning?: boolean experimental?: ExperimentalConfig + world?: WorldConfig sandbox?: SandboxConfig indexing?: IndexingConfig } diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts b/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts index d7000fd4d08..af7b6e322ce 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts @@ -20,14 +20,7 @@ import type { AnacondaDesktopExtensionMessage } from "../../../../src/shared/ana import type { QuestionRequest, SuggestionRequest, TodoItem } from "./questions" import type { ModelSelection, Provider, ProviderAuthState } from "./providers" import type { AgentInfo, AgentRequirementResult, SkillInfo, SlashCommandInfo } from "./agents" -import type { - BrowserSettings, - Config, - ConfigCollections, - FeatureFlags, - IndexingStatus, - KiloEmbeddingModelCatalog, -} from "./config" +import type { Config, ConfigCollections, FeatureFlags, IndexingStatus, KiloEmbeddingModelCatalog } from "./config" import type { WorkStyle, WorkStyleState } from "../../../../src/shared/work-style-presets" import type { KilocodeNotification, ProfileData } from "./profile" import type { @@ -563,11 +556,6 @@ export interface SuggestionErrorMessage { requestID: string } -export interface BrowserSettingsLoadedMessage { - type: "browserSettingsLoaded" - settings: BrowserSettings -} - export interface ClaudeCompatSettingLoadedMessage { type: "claudeCompatSettingLoaded" enabled: boolean @@ -1328,7 +1316,6 @@ export type ExtensionMessage = | SuggestionRequestMessage | SuggestionResolvedMessage | SuggestionErrorMessage - | BrowserSettingsLoadedMessage | ClaudeCompatSettingLoadedMessage | ConfigLoadedMessage | ConfigUpdatedMessage diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts b/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts index 4f11b99d7c2..f1cc0cc095a 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts @@ -474,10 +474,6 @@ export interface StreamSessionVisibleMessage { visible: boolean } -export interface RequestBrowserSettingsMessage { - type: "requestBrowserSettings" -} - export interface RequestClaudeCompatSettingMessage { type: "requestClaudeCompatSetting" } @@ -1428,7 +1424,6 @@ export type WebviewMessage = | SetWorkStyleMessage | ApplyWorkStyleMessage | StreamSessionVisibleMessage - | RequestBrowserSettingsMessage | RequestClaudeCompatSettingMessage | RequestConfigMessage | RequestGlobalConfigMessage diff --git a/packages/kilo-world/src/config.ts b/packages/kilo-world/src/config.ts index e972313db1b..724f6b4db9b 100644 --- a/packages/kilo-world/src/config.ts +++ b/packages/kilo-world/src/config.ts @@ -17,19 +17,33 @@ export function hasDisplay(): boolean { export function defaultConfig(): WorldConfig { const headless = !hasDisplay() || process.env["KILO_WORLD_HEADED"] !== "1" + const args = parse() return { browser: { headless, antiDetect: process.env["KILO_WORLD_ANTI_DETECT"] === "1", timeoutMs: 30_000, viewport: { width: 1280, height: 720 }, - args: [], + args, ...(process.env["KILO_WORLD_CHROMIUM"] ? { executablePath: process.env["KILO_WORLD_CHROMIUM"] } : {}), + ...(process.env["KILO_WORLD_SYSTEM_CHROME"] === "1" ? { useSystemChrome: true } : {}), }, home: process.env["KILO_WORLD_HOME"] ?? DEFAULT_HOME, } } +function parse(): string[] { + const raw = process.env["KILO_WORLD_ARGS"] + if (!raw) return [] + try { + const value: unknown = JSON.parse(raw) + if (Array.isArray(value) && value.every((item) => typeof item === "string")) return value + return [] + } catch { + return [] + } +} + export function getConfig(): WorldConfig { if (!cached) cached = defaultConfig() return cached diff --git a/packages/kilo-world/src/daemon/client.ts b/packages/kilo-world/src/daemon/client.ts index 0a1f8161aaa..4fd6ec4328d 100644 --- a/packages/kilo-world/src/daemon/client.ts +++ b/packages/kilo-world/src/daemon/client.ts @@ -1,4 +1,5 @@ import { closeSync, constants, existsSync, openSync, readFileSync } from "node:fs" +import { createHash } from "node:crypto" import { spawn, type ChildProcess } from "node:child_process" import { fileURLToPath } from "node:url" import { dirname, join } from "node:path" @@ -12,10 +13,15 @@ import { BUILD_TIMEOUT_MS, ENTRY, fingerprint, fresh, MANIFEST } from "./build" const SCRIPT_HEAD_TIMEOUT_MS = 30_000 const BUILD_OUTPUT_MAX = 16_384 const BUILD_FAILURE_COOLDOWN_MS = 30_000 +const PING_TIMEOUT_MS = 2000 const starts = new Map>() +const configs = new Map>() +const children = new Map() let building: { key: string; task: Promise } | null = null let failed: { key: string; err: Error; at: number } | null = null +type LaunchConfig = Pick + function root(): string { const here = dirname(fileURLToPath(import.meta.url)) return join(here, "..", "..") @@ -136,30 +142,50 @@ export namespace DaemonClient { startedAt: number url: string token: string + launchKey?: string } | null { const path = DaemonServer.handshakePath(sessionID) if (!existsSync(path)) return null try { const data: unknown = JSON.parse(readFileSync(path, "utf8")) if (!isHandshake(data)) return null - return { pid: data.pid, startedAt: data.startedAt ?? 0, url: data.url, token: data.token } + return { + pid: data.pid, + startedAt: data.startedAt ?? 0, + url: data.url, + token: data.token, + ...(data.launchKey ? { launchKey: data.launchKey } : {}), + } } catch { return null } } export async function ensureRunning(sessionID: string, opts: CallOptions & { idleMs?: number } = {}): Promise { + if (await responsive(sessionID)) return + if (isRunning(sessionID)) { + await terminate(sessionID, DaemonServer.shutdownTimeoutMs) + if (isRunning(sessionID)) throw new Error(`kilo-world daemon for session ${sessionID} is not responding`) + } + return ensure(sessionID, opts, launchConfig(getConfig())) + } + + async function ensure(sessionID: string, opts: CallOptions & { idleMs?: number }, cfg: LaunchConfig): Promise { if (await ping(sessionID)) return const current = starts.get(sessionID) if (current) return current - const pending = launch(sessionID, opts).finally(() => { + const pending = launch(sessionID, opts, cfg).finally(() => { if (starts.get(sessionID) === pending) starts.delete(sessionID) }) starts.set(sessionID, pending) return pending } - async function launch(sessionID: string, opts: CallOptions & { idleMs?: number }): Promise { + async function launch( + sessionID: string, + opts: CallOptions & { idleMs?: number }, + launch: LaunchConfig, + ): Promise { const file = await entry(opts) const bin = runtime() if (process.env["KILO_WORLD_NODE"] && /[\\/]/.test(bin) && !existsSync(bin)) { @@ -176,8 +202,13 @@ export namespace DaemonClient { KILO_WORLD_DAEMON_SESSION: sessionID, KILO_WORLD_PARENT_PID: String(process.pid), KILO_WORLD_HOME: cfg.home, - ...(process.versions.electron ? { ELECTRON_RUN_AS_NODE: "1" } : {}), - ...(cfg.browser.executablePath ? { KILO_WORLD_CHROMIUM: cfg.browser.executablePath } : {}), + ...(process.versions.electron || process.env["KILO_WORLD_NODE_ELECTRON"] === "1" + ? { ELECTRON_RUN_AS_NODE: "1" } + : {}), + ...(launch.executablePath ? { KILO_WORLD_CHROMIUM: launch.executablePath } : {}), + KILO_WORLD_ARGS: JSON.stringify(launch.args), + KILO_WORLD_SYSTEM_CHROME: launch.useSystemChrome ? "1" : "0", + KILO_WORLD_LAUNCH_KEY: launchKey(launch), ...(opts.idleMs !== undefined ? { KILO_WORLD_DAEMON_IDLE_MS: String(opts.idleMs) } : {}), } const args = [file, `--session=${sessionID}`, ...(opts.idleMs !== undefined ? [`--idle=${opts.idleMs}`] : [])] @@ -197,6 +228,10 @@ export namespace DaemonClient { child.once("error", (err) => { state.err = err }) + children.set(sessionID, child) + child.once("close", () => { + if (children.get(sessionID) === child) children.delete(sessionID) + }) child.unref() const start = Date.now() while (Date.now() - start < SCRIPT_HEAD_TIMEOUT_MS) { @@ -223,12 +258,21 @@ export namespace DaemonClient { ) } - async function ping(sessionID: string): Promise { + async function responsive(sessionID: string): Promise { + if (await ping(sessionID)) return true + for (const _ of [0, 1]) { + if (!isRunning(sessionID)) return false + if (await ping(sessionID, PING_TIMEOUT_MS)) return true + } + return false + } + + async function ping(sessionID: string, timeout = 500): Promise { if (!isRunning(sessionID)) return false const hs = handshake(sessionID) if (!hs) return false const controller = new AbortController() - const timer = setTimeout(() => controller.abort(), 500) + const timer = setTimeout(() => controller.abort(), timeout) return fetch(`${hs.url}/call`, { method: "POST", headers: { "content-type": "application/json" }, @@ -262,10 +306,14 @@ export namespace DaemonClient { export async function stop(sessionID: string, opts: CallOptions = {}): Promise { if (!isRunning(sessionID)) return false try { - const resp = await call( + const resp = await send( sessionID, { id: randomId(), verb: "__shutdown__", args: [] }, - { ...opts, timeoutMs: opts.timeoutMs ?? 5000, silent: true }, + { + ...opts, + timeoutMs: opts.timeoutMs ?? 5000, + silent: true, + }, ) return resp.ok } catch { @@ -432,6 +480,9 @@ export namespace DaemonClient { const segments = parseScript(script) const startedAt = Date.now() const results: RunResult["results"] = [] + opts.signal?.throwIfAborted() + const cfg = opts.config ?? getConfig() + await configured(sessionID, cfg, opts) for (const seg of segments) { if (opts.signal?.aborted) throw new Error("world script aborted") @@ -441,7 +492,7 @@ export namespace DaemonClient { results.push({ ok: true, verb: seg.verb, args: seg.args, data, durationMs: 0 }) continue } - const response = await call( + const response = await send( sessionID, { id: randomId(), @@ -460,6 +511,115 @@ export namespace DaemonClient { return { ok: results.every((r) => r.ok), durationMs: Date.now() - startedAt, results } } + + async function configured(sessionID: string, cfg: WorldConfig, opts: CallOptions): Promise { + const previous = configs.get(sessionID) ?? Promise.resolve() + const pending = previous.catch(() => undefined).then(() => apply(sessionID, cfg, opts)) + configs.set(sessionID, pending) + await pending.finally(() => { + if (configs.get(sessionID) === pending) configs.delete(sessionID) + }) + } + + async function apply(sessionID: string, cfg: WorldConfig, opts: CallOptions): Promise { + const launch = launchConfig(cfg) + const key = launchKey(launch) + const healthy = await responsive(sessionID) + if (runningWith(sessionID, key) && healthy) return + if (isRunning(sessionID)) { + if (healthy) { + await stop(sessionID, { timeoutMs: DaemonServer.shutdownTimeoutMs, silent: true }) + await gone(sessionID, DaemonServer.shutdownTimeoutMs) + } + if (isRunning(sessionID)) await terminate(sessionID, DaemonServer.shutdownTimeoutMs) + if (isRunning(sessionID)) throw new Error(`kilo-world daemon for session ${sessionID} did not stop`) + } + await ensure(sessionID, opts, launch) + } + + function runningWith(sessionID: string, key: string): boolean { + if (!isRunning(sessionID)) return false + const hs = handshake(sessionID) + return hs?.launchKey === key + } +} + +function launchConfig(cfg: WorldConfig): LaunchConfig { + return { + ...(cfg.browser.executablePath ? { executablePath: cfg.browser.executablePath } : {}), + useSystemChrome: cfg.browser.useSystemChrome ?? false, + args: [...cfg.browser.args], + } +} + +function launchKey(cfg: LaunchConfig): string { + return createHash("sha256").update(JSON.stringify(cfg)).digest("hex") +} + +async function gone(sessionID: string, timeout: number): Promise { + const start = Date.now() + while (DaemonClient.isRunning(sessionID) && Date.now() - start < timeout) { + await new Promise((resolve) => setTimeout(resolve, 25)) + } +} + +async function terminate(sessionID: string, timeout: number): Promise { + const hs = DaemonClient.handshake(sessionID) + if (!hs) return + const child = children.get(sessionID) + if (child?.pid !== hs.pid && !(await attributable(hs.pid, hs.startedAt))) return + const current = DaemonClient.handshake(sessionID) + if (!current || current.pid !== hs.pid || current.startedAt !== hs.startedAt || current.token !== hs.token) return + signal(hs.pid, "SIGTERM") + await gone(sessionID, timeout) + if (!DaemonClient.isRunning(sessionID)) { + DaemonServer.clear(sessionID, hs.pid) + return + } + const latest = DaemonClient.handshake(sessionID) + if (!latest || latest.pid !== hs.pid || latest.startedAt !== hs.startedAt || latest.token !== hs.token) return + signal(hs.pid, "SIGKILL") + await gone(sessionID, 1000) + if (!DaemonClient.isRunning(sessionID)) DaemonServer.clear(sessionID, hs.pid) +} + +async function attributable(pid: number, expected: number): Promise { + const actual = await started(pid) + return actual !== null && Math.abs(actual - expected) < 5000 +} + +function started(pid: number): Promise { + const bin = process.platform === "win32" ? "powershell.exe" : "ps" + const args = + process.platform === "win32" + ? [ + "-NoProfile", + "-NonInteractive", + "-Command", + `(Get-Process -Id ${pid} -ErrorAction Stop).StartTime.ToUniversalTime().ToString('o')`, + ] + : ["-o", "lstart=", "-p", String(pid)] + return new Promise((resolve) => { + const child = spawn(bin, args, { stdio: ["ignore", "pipe", "ignore"], windowsHide: true }) + const output: Buffer[] = [] + child.stdout?.on("data", (data: Buffer) => output.push(data)) + child.once("error", () => resolve(null)) + child.once("close", (code) => { + if (code !== 0) return resolve(null) + const value = Date.parse(Buffer.concat(output).toString().trim()) + resolve(Number.isFinite(value) ? value : null) + }) + }) +} + +function signal(pid: number, name: NodeJS.Signals): void { + try { + process.kill(process.platform === "win32" ? pid : -pid, name) + } catch (err) { + if (!(err instanceof Error) || !("code" in err) || err.code !== "ESRCH") { + process.stderr.write(`failed to signal world daemon ${pid}: ${String(err)}\n`) + } + } } function kill(child: ChildProcess): void { @@ -482,9 +642,6 @@ function daemonConfig(cfg: WorldConfig): DaemonConfig { antiDetect: cfg.browser.antiDetect, timeoutMs: cfg.browser.timeoutMs, viewport: cfg.browser.viewport, - executablePath: cfg.browser.executablePath, - useSystemChrome: cfg.browser.useSystemChrome ?? false, - args: [...cfg.browser.args], }, } } diff --git a/packages/kilo-world/src/daemon/protocol.ts b/packages/kilo-world/src/daemon/protocol.ts index 843ee080316..64025720d2d 100644 --- a/packages/kilo-world/src/daemon/protocol.ts +++ b/packages/kilo-world/src/daemon/protocol.ts @@ -37,6 +37,7 @@ export type DaemonHandshake = { startedAt: number idleTimeoutMs: number sessionID: string + launchKey?: string /** * Base URL of the per-session HTTP endpoint, e.g. `http://127.0.0.1:54321`. * Single source of truth — clients POST to `${url}/call` and need not @@ -62,6 +63,7 @@ export function isHandshake(value: unknown): value is DaemonHandshake { typeof value.startedAt === "number" && typeof value.idleTimeoutMs === "number" && typeof value.sessionID === "string" && + (value.launchKey === undefined || typeof value.launchKey === "string") && typeof value.url === "string" && typeof value.token === "string" ) @@ -86,17 +88,10 @@ function config(value: unknown): value is DaemonConfig { if (Object.keys(value).some((key) => key !== "browser")) return false if (value.browser === undefined) return true if (!record(value.browser)) return false - const keys = ["headless", "antiDetect", "timeoutMs", "viewport", "executablePath", "useSystemChrome", "args"] + const keys = ["headless", "antiDetect", "timeoutMs", "viewport"] if (Object.keys(value.browser).some((key) => !keys.includes(key))) return false if (value.browser.headless !== undefined && typeof value.browser.headless !== "boolean") return false if (value.browser.antiDetect !== undefined && typeof value.browser.antiDetect !== "boolean") return false - if (value.browser.useSystemChrome !== undefined && typeof value.browser.useSystemChrome !== "boolean") return false - if (value.browser.executablePath !== undefined && typeof value.browser.executablePath !== "string") return false - if ( - value.browser.args !== undefined && - (!Array.isArray(value.browser.args) || !value.browser.args.every((item) => typeof item === "string")) - ) - return false if (value.browser.timeoutMs !== undefined && typeof value.browser.timeoutMs !== "number") return false if (value.browser.viewport === undefined) return true if (!record(value.browser.viewport)) return false diff --git a/packages/kilo-world/src/daemon/server.ts b/packages/kilo-world/src/daemon/server.ts index 1aca05d0729..997621f23af 100644 --- a/packages/kilo-world/src/daemon/server.ts +++ b/packages/kilo-world/src/daemon/server.ts @@ -46,6 +46,8 @@ function remove(file: string): void { } export namespace DaemonServer { + export const shutdownTimeoutMs = SERVER_CLOSE_TIMEOUT_MS + BROWSER_CLOSE_TIMEOUT_MS + 1000 + export type Options = { sessionID: string idleTimeoutMs?: number @@ -72,6 +74,7 @@ export namespace DaemonServer { pid: number startedAt: number sessionID?: string + launchKey?: string url: string token: string } | null { @@ -84,6 +87,7 @@ export namespace DaemonServer { pid: data.pid, startedAt: data.startedAt ?? 0, ...(data.sessionID ? { sessionID: data.sessionID } : {}), + ...(data.launchKey ? { launchKey: data.launchKey } : {}), url: data.url, token: data.token, } @@ -455,6 +459,7 @@ export namespace DaemonServer { startedAt: Date.now(), idleTimeoutMs, sessionID, + ...(process.env["KILO_WORLD_LAUNCH_KEY"] ? { launchKey: process.env["KILO_WORLD_LAUNCH_KEY"] } : {}), url, token: currentAuthToken, } diff --git a/packages/kilo-world/src/types.ts b/packages/kilo-world/src/types.ts index c327566537b..6dabcdea98e 100644 --- a/packages/kilo-world/src/types.ts +++ b/packages/kilo-world/src/types.ts @@ -85,10 +85,7 @@ export type WorldConfigPatch = Omit, "browser"> & { } export type DaemonConfig = { - browser?: Pick< - BrowserConfig, - "headless" | "antiDetect" | "timeoutMs" | "viewport" | "executablePath" | "useSystemChrome" | "args" - > + browser?: Pick } export type Action = { diff --git a/packages/kilo-world/test/daemon.test.ts b/packages/kilo-world/test/daemon.test.ts index f15e00bec76..b600b8c880b 100644 --- a/packages/kilo-world/test/daemon.test.ts +++ b/packages/kilo-world/test/daemon.test.ts @@ -83,7 +83,7 @@ describe("world daemon", () => { verb: "status", args: [], auth: handshake!.token, - config: { browser: { unknown: true } }, + config: { browser: { executablePath: "/tmp/browser", args: ["--load-extension=/tmp/unsafe"] } }, }), }) expect(unsafe.status).toBe(400) @@ -125,6 +125,24 @@ describe("world daemon", () => { expect(await World.daemonStatus(session)).toMatchObject({ runtime: "node", runtimeVersion: expect.any(String) }) }) + test("pins trusted launch settings at startup and restarts when they change", async () => { + const before = DaemonClient.handshake(session) + const current = World.currentConfig() + const result = await World.runForSession(session, "status", { + config: { + ...current, + browser: { ...current.browser, args: [...current.browser.args, "--disable-notifications"] }, + }, + }) + const after = DaemonClient.handshake(session) + + expect(result.ok).toBe(true) + expect(before?.launchKey).toBeString() + expect(after?.launchKey).toBeString() + expect(after?.launchKey).not.toBe(before?.launchKey) + expect(after?.pid).not.toBe(before?.pid) + }) + test.skipIf(!available)("isolates browser state and processes between sessions", async () => { const [first, second] = await Promise.all([ World.runForSession( diff --git a/packages/kilo-world/test/launch.test.ts b/packages/kilo-world/test/launch.test.ts index 0401346474e..5447805fbc8 100644 --- a/packages/kilo-world/test/launch.test.ts +++ b/packages/kilo-world/test/launch.test.ts @@ -1,6 +1,7 @@ import { afterAll, describe, expect, test } from "bun:test" import { Launch } from "../src/core/browser/launch" import { findSystemChrome } from "../src/core/browser/chrome" +import { defaultConfig } from "../src/config" import type { WorldConfig } from "../src/types" afterAll(() => {}) @@ -20,6 +21,19 @@ function cfg(patch: Partial): WorldConfig { } describe("Launch.fromConfig", () => { + test("ignores malformed daemon browser arguments", () => { + const current = process.env["KILO_WORLD_ARGS"] + try { + process.env["KILO_WORLD_ARGS"] = "not-json" + expect(defaultConfig().browser.args).toEqual([]) + process.env["KILO_WORLD_ARGS"] = JSON.stringify(["--valid", 1]) + expect(defaultConfig().browser.args).toEqual([]) + } finally { + if (current === undefined) delete process.env["KILO_WORLD_ARGS"] + else process.env["KILO_WORLD_ARGS"] = current + } + }) + test("uses executablePath when explicitly set", () => { const out = Launch.fromConfig(cfg({ executablePath: "/custom/chrome" })) expect(out.executablePath).toBe("/custom/chrome") diff --git a/packages/opencode/src/kilocode/tool/registry.ts b/packages/opencode/src/kilocode/tool/registry.ts index 8efd0c011c2..e2bfd071891 100644 --- a/packages/opencode/src/kilocode/tool/registry.ts +++ b/packages/opencode/src/kilocode/tool/registry.ts @@ -264,7 +264,9 @@ export namespace KiloToolRegistry { ...(Flag.KILO_CLIENT === "vscode" ? [tools.chart] : []), ...(Flag.KILO_CLIENT === "cli" || Flag.KILO_CLIENT === "vscode" ? [tools.process] : []), ...(Flag.KILO_CLIENT === "cli" && tools.terminal ? [tools.terminal] : []), - ...(Flag.KILO_CLIENT === "cli" && cfg.experimental?.world_browser !== false ? [tools.world] : []), + ...((Flag.KILO_CLIENT === "cli" || Flag.KILO_CLIENT === "vscode") && cfg.experimental?.world_browser !== false + ? [tools.world] + : []), ...(Flag.KILO_CLIENT === "vscode" ? [tools.managerModels, tools.manager] : []), ...(Flag.KILO_CLIENT === "vscode" && cfg.experimental?.native_notebook_tools === true && diff --git a/packages/opencode/test/kilocode/tool-registry-indexing.test.ts b/packages/opencode/test/kilocode/tool-registry-indexing.test.ts index b80175b601e..7b219faf1d0 100644 --- a/packages/opencode/test/kilocode/tool-registry-indexing.test.ts +++ b/packages/opencode/test/kilocode/tool-registry-indexing.test.ts @@ -405,6 +405,7 @@ describe("kilocode tool registry indexing", () => { "recall", "chart", "background_process", + "world", "agent_manager_models", "agent_manager", "notify_user", @@ -423,6 +424,7 @@ describe("kilocode tool registry indexing", () => { "recall", "chart", "background_process", + "world", "agent_manager_models", "agent_manager", "notebook_read", @@ -437,12 +439,12 @@ describe("kilocode tool registry indexing", () => { "recall", "chart", "background_process", + "world", "agent_manager_models", "agent_manager", "notify_user", "send_file", ]) - process.env["KILO_CLIENT"] = "desktop" expect(KiloToolRegistry.extra(tools, {}).map((tool) => tool.id)).toEqual([ "semantic_search",