Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/world-tool.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ node_modules
playground
tmp
dist
packages/kilo-world/.world-daemon-*
ts-dist
storybook-static
.turbo
Expand Down
20 changes: 19 additions & 1 deletion bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
33 changes: 33 additions & 0 deletions packages/core/src/v1/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Comment thread
IamCoder18 marked this conversation as resolved.
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({
Expand Down Expand Up @@ -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.",
Expand Down
39 changes: 39 additions & 0 deletions packages/kilo-world/package.json
Original file line number Diff line number Diff line change
@@ -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:"
}
}
116 changes: 116 additions & 0 deletions packages/kilo-world/script/build-daemon.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
if (await fresh(dir, key, WorldDaemon.filename, WorldDaemon.manifest)) return
const stage = await fs.mkdtemp(path.join(root, ".world-daemon-"))
Comment thread
IamCoder18 marked this conversation as resolved.
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<fs.FileHandle> {
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<boolean> {
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<void> {
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<string[]> {
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<void> {
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 })),
)
}
105 changes: 105 additions & 0 deletions packages/kilo-world/script/daemon.ts
Original file line number Diff line number Diff line change
@@ -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<Bundle> {
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<string> {
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<void> {
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<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
}
6 changes: 6 additions & 0 deletions packages/kilo-world/src/client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export { defaultConfig, ensureHome, getConfig, hasDisplay, 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"
Loading
Loading