Skip to content
Closed
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
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
19 changes: 18 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",
Comment thread
IamCoder18 marked this conversation as resolved.
"semver": "7.7.4",
"typescript": "5.8.2",
"@typescript/native-preview": "7.0.0-dev.20260316.1",
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
Comment thread
IamCoder18 marked this conversation as resolved.
} 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({
Comment thread
IamCoder18 marked this conversation as resolved.
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, 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"
25 changes: 25 additions & 0 deletions packages/kilo-world/src/commands/browser/click.ts
Original file line number Diff line number Diff line change
@@ -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 } : {}),
}
}
}
9 changes: 9 additions & 0 deletions packages/kilo-world/src/commands/browser/close.ts
Original file line number Diff line number Diff line change
@@ -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 }
}
}
Loading
Loading