Resolve merge conflicts

This commit is contained in:
Kevin van Dijk
2026-02-23 20:12:28 +01:00
298 changed files with 15340 additions and 5719 deletions
+10
View File
@@ -44,6 +44,16 @@ opencode acp
opencode acp --cwd /path/to/project
```
### Question Tool Opt-In
ACP excludes `QuestionTool` by default.
```bash
OPENCODE_ENABLE_QUESTION_TOOL=1 opencode acp
```
Enable this only for ACP clients that support interactive question prompts.
### Programmatic
```typescript
+2 -2
View File
@@ -30,6 +30,7 @@ import {
import { Log } from "../util/log"
import { pathToFileURL } from "bun"
import { Filesystem } from "../util/filesystem"
import { ACPSessionManager } from "./session"
import type { ACPConfig } from "./types"
import { Provider } from "../provider/provider"
@@ -230,8 +231,7 @@ export namespace ACP {
const metadata = permission.metadata || {}
const filepath = typeof metadata["filepath"] === "string" ? metadata["filepath"] : ""
const diff = typeof metadata["diff"] === "string" ? metadata["diff"] : ""
const file = Bun.file(filepath)
const content = (await file.exists()) ? await file.text() : ""
const content = (await Filesystem.exists(filepath)) ? await Filesystem.readText(filepath) : ""
const newContent = getNewContent(content, diff)
if (newContent) {
-1
View File
@@ -21,7 +21,6 @@ export class ACPSessionManager {
const session = await this.sdk.session
.create(
{
title: `ACP Session ${crypto.randomUUID()}`,
directory: cwd,
},
{ throwOnError: true },
+4 -3
View File
@@ -57,13 +57,13 @@ export namespace Agent {
const cfg = await Config.get()
const skillDirs = await Skill.dirs()
const whitelistedDirs = [Truncate.GLOB, ...skillDirs.map((dir) => path.join(dir, "*"))]
const defaults = PermissionNext.fromConfig({
"*": "allow",
doom_loop: "ask",
external_directory: {
"*": "ask",
[Truncate.GLOB]: "allow",
...Object.fromEntries(skillDirs.map((dir) => [path.join(dir, "*"), "allow"])),
...Object.fromEntries(whitelistedDirs.map((dir) => [dir, "allow"])),
},
question: "deny",
plan_enter: "deny",
@@ -228,7 +228,8 @@ export namespace Agent {
codesearch: "allow",
read: "allow",
external_directory: {
[Truncate.GLOB]: "allow",
"*": "ask",
...Object.fromEntries(whitelistedDirs.map((dir) => [dir, "allow"])),
},
}),
user,
+4 -6
View File
@@ -2,6 +2,7 @@ import path from "path"
import { Global } from "../global"
import z from "zod"
import { Telemetry } from "@kilocode/kilo-telemetry" // kilocode_change
import { Filesystem } from "../util/filesystem"
export const OAUTH_DUMMY_KEY = "opencode-oauth-dummy-key"
@@ -43,8 +44,7 @@ export namespace Auth {
}
export async function all(): Promise<Record<string, Info>> {
const file = Bun.file(filepath)
const data = await file.json().catch(() => ({}) as Record<string, unknown>)
const data = await Filesystem.readJson<Record<string, unknown>>(filepath).catch(() => ({}))
return Object.entries(data).reduce(
(acc, [key, value]) => {
const parsed = Info.safeParse(value)
@@ -57,16 +57,14 @@ export namespace Auth {
}
export async function set(key: string, info: Info) {
const file = Bun.file(filepath)
const data = await all()
await Bun.write(file, JSON.stringify({ ...data, [key]: info }, null, 2), { mode: 0o600 })
await Filesystem.writeJson(filepath, { ...data, [key]: info }, 0o600)
}
export async function remove(key: string) {
const file = Bun.file(filepath)
const data = await all()
delete data[key]
await Bun.write(file, JSON.stringify(data, null, 2), { mode: 0o600 })
await Filesystem.writeJson(filepath, data, 0o600)
// kilocode_change start - Track logout and reset telemetry identity for Kilo
if (key === "kilo") {
+10 -9
View File
@@ -66,14 +66,14 @@ export namespace BunProc {
using _ = await Lock.write("bun-install")
const mod = path.join(Global.Path.cache, "node_modules", pkg)
const pkgjson = Bun.file(path.join(Global.Path.cache, "package.json"))
const parsed = await pkgjson.json().catch(async () => {
const result = { dependencies: {} }
await Bun.write(pkgjson.name!, JSON.stringify(result, null, 2))
const pkgjsonPath = path.join(Global.Path.cache, "package.json")
const parsed = await Filesystem.readJson<{ dependencies: Record<string, string> }>(pkgjsonPath).catch(async () => {
const result = { dependencies: {} as Record<string, string> }
await Filesystem.writeJson(pkgjsonPath, result)
return result
})
const dependencies = parsed.dependencies ?? {}
if (!parsed.dependencies) parsed.dependencies = dependencies
if (!parsed.dependencies) parsed.dependencies = {} as Record<string, string>
const dependencies = parsed.dependencies
const modExists = await Filesystem.exists(mod)
const cachedVersion = dependencies[pkg]
@@ -123,15 +123,16 @@ export namespace BunProc {
// This ensures subsequent starts use the cached version until explicitly updated
let resolvedVersion = version
if (version === "latest") {
const installedPkgJson = Bun.file(path.join(mod, "package.json"))
const installedPkg = await installedPkgJson.json().catch(() => null)
const installedPkg = await Filesystem.readJson<{ version?: string }>(path.join(mod, "package.json")).catch(
() => null,
)
if (installedPkg?.version) {
resolvedVersion = installedPkg.version
}
}
parsed.dependencies[pkg] = resolvedVersion
await Bun.write(pkgjson.name!, JSON.stringify(parsed, null, 2))
await Filesystem.writeJson(pkgjsonPath, parsed)
return mod
}
}
+3 -3
View File
@@ -6,6 +6,7 @@ import { Agent } from "../../agent/agent"
import { Provider } from "../../provider/provider"
import path from "path"
import fs from "fs/promises"
import { Filesystem } from "../../util/filesystem"
import matter from "gray-matter"
import { Instance } from "../../project/instance"
import { EOL } from "os"
@@ -202,8 +203,7 @@ const AgentCreateCommand = cmd({
await fs.mkdir(targetPath, { recursive: true })
const file = Bun.file(filePath)
if (await file.exists()) {
if (await Filesystem.exists(filePath)) {
if (isFullyNonInteractive) {
console.error(`Error: Agent file already exists: ${filePath}`)
process.exit(1)
@@ -212,7 +212,7 @@ const AgentCreateCommand = cmd({
throw new UI.CancelledError()
}
await Bun.write(filePath, content)
await Filesystem.write(filePath, content)
if (isFullyNonInteractive) {
console.log(filePath)
+45
View File
@@ -159,6 +159,38 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string):
return false
}
/**
* Build a deduplicated list of plugin-registered auth providers that are not
* already present in models.dev, respecting enabled/disabled provider lists.
* Pure function with no side effects; safe to test without mocking.
*/
export function resolvePluginProviders(input: {
hooks: Hooks[]
existingProviders: Record<string, unknown>
disabled: Set<string>
enabled?: Set<string>
providerNames: Record<string, string | undefined>
}): Array<{ id: string; name: string }> {
const seen = new Set<string>()
const result: Array<{ id: string; name: string }> = []
for (const hook of input.hooks) {
if (!hook.auth) continue
const id = hook.auth.provider
if (seen.has(id)) continue
seen.add(id)
if (Object.hasOwn(input.existingProviders, id)) continue
if (input.disabled.has(id)) continue
if (input.enabled && !input.enabled.has(id)) continue
result.push({
id,
name: input.providerNames[id] ?? id,
})
}
return result
}
export const AuthCommand = cmd({
command: "auth",
describe: "manage credentials",
@@ -287,6 +319,14 @@ export const AuthLoginCommand = cmd({
vercel: 7,
}
// kilocode_change end
const pluginProviders = resolvePluginProviders({
hooks: await Plugin.list(),
existingProviders: providers,
disabled,
enabled,
providerNames: Object.fromEntries(Object.entries(config.provider ?? {}).map(([id, p]) => [id, p.name])),
})
let provider = await prompts.autocomplete({
message: "Select provider",
maxItems: 8,
@@ -309,6 +349,11 @@ export const AuthLoginCommand = cmd({
}[x.id],
})),
),
...pluginProviders.map((x) => ({
label: x.name,
value: x.id,
hint: "plugin",
})),
{
value: "other",
label: "Other",
+1 -1
View File
@@ -18,7 +18,7 @@ export const ExportCommand = cmd({
handler: async (args) => {
await bootstrap(process.cwd(), async () => {
let sessionID = args.sessionID
process.stderr.write(`Exporting session: ${sessionID ?? "latest"}`)
process.stderr.write(`Exporting session: ${sessionID ?? "latest"}\n`)
if (!sessionID) {
UI.empty()
+127 -39
View File
@@ -1,5 +1,6 @@
import path from "path"
import { exec } from "child_process"
import { Filesystem } from "../../util/filesystem"
import * as prompts from "@clack/prompts"
import { map, pipe, sortBy, values } from "remeda"
import { Octokit } from "@octokit/rest"
@@ -173,6 +174,18 @@ export function extractResponseText(parts: MessageV2.Part[]): string | null {
throw new Error("Failed to parse response: no parts returned")
}
/**
* Formats a PROMPT_TOO_LARGE error message with details about files in the prompt.
* Content is base64 encoded, so we calculate original size by multiplying by 0.75.
*/
export function formatPromptTooLargeError(files: { filename: string; content: string }[]): string {
const fileDetails =
files.length > 0
? `\n\nFiles in prompt:\n${files.map((f) => ` - ${f.filename} (${((f.content.length * 0.75) / 1024).toFixed(0)} KB)`).join("\n")}`
: ""
return `PROMPT_TOO_LARGE: The prompt exceeds the model's context limit.${fileDetails}`
}
export const GithubCommand = cmd({
command: "github",
describe: "manage GitHub agent",
@@ -368,7 +381,7 @@ export const GithubInstallCommand = cmd({
const envStr = providerEnvStr || kiloGatewayEnv ? `\n env:${providerEnvStr}${kiloGatewayEnv}` : ""
await Bun.write(
await Filesystem.write(
path.join(app.root, WORKFLOW_FILE),
`name: kilo
@@ -549,8 +562,12 @@ export const GithubRunCommand = cmd({
const branch = await checkoutNewBranch(branchPrefix)
const head = (await $`git rev-parse HEAD`).stdout.toString().trim()
const response = await chat(userPrompt, promptFiles)
const { dirty, uncommittedChanges } = await branchIsDirty(head)
if (dirty) {
const { dirty, uncommittedChanges, switched } = await branchIsDirty(head, branch)
if (switched) {
// Agent switched branches (likely created its own branch/PR)
console.log("Agent managed its own branch, skipping infrastructure push/PR")
console.log("Response:", response)
} else if (dirty) {
const summary = await summarize(response)
// workflow_dispatch has an actor for co-author attribution, schedule does not
await pushToNewBranch(summary, branch, uncommittedChanges, isScheduleEvent)
@@ -561,7 +578,11 @@ export const GithubRunCommand = cmd({
summary,
`${response}\n\nTriggered by ${triggerType}${footer({ image: true })}`,
)
console.log(`Created PR #${pr}`)
if (pr) {
console.log(`Created PR #${pr}`)
} else {
console.log("Skipped PR creation (no new commits)")
}
} else {
console.log("Response:", response)
}
@@ -576,8 +597,11 @@ export const GithubRunCommand = cmd({
const head = (await $`git rev-parse HEAD`).stdout.toString().trim()
const dataPrompt = buildPromptDataForPR(prData)
const response = await chat(`${userPrompt}\n\n${dataPrompt}`, promptFiles)
const { dirty, uncommittedChanges } = await branchIsDirty(head)
if (dirty) {
const { dirty, uncommittedChanges, switched } = await branchIsDirty(head, prData.headRefName)
if (switched) {
console.log("Agent managed its own branch, skipping infrastructure push")
}
if (dirty && !switched) {
const summary = await summarize(response)
await pushToLocalBranch(summary, uncommittedChanges)
}
@@ -587,12 +611,15 @@ export const GithubRunCommand = cmd({
}
// Fork PR
else {
await checkoutForkBranch(prData)
const forkBranch = await checkoutForkBranch(prData)
const head = (await $`git rev-parse HEAD`).stdout.toString().trim()
const dataPrompt = buildPromptDataForPR(prData)
const response = await chat(`${userPrompt}\n\n${dataPrompt}`, promptFiles)
const { dirty, uncommittedChanges } = await branchIsDirty(head)
if (dirty) {
const { dirty, uncommittedChanges, switched } = await branchIsDirty(head, forkBranch)
if (switched) {
console.log("Agent managed its own branch, skipping infrastructure push")
}
if (dirty && !switched) {
const summary = await summarize(response)
await pushToForkBranch(summary, prData, uncommittedChanges)
}
@@ -608,8 +635,13 @@ export const GithubRunCommand = cmd({
const issueData = await fetchIssue()
const dataPrompt = buildPromptDataForIssue(issueData)
const response = await chat(`${userPrompt}\n\n${dataPrompt}`, promptFiles)
const { dirty, uncommittedChanges } = await branchIsDirty(head)
if (dirty) {
const { dirty, uncommittedChanges, switched } = await branchIsDirty(head, branch)
if (switched) {
// Agent switched branches (likely created its own branch/PR).
// Don't push the stale infrastructure branch — just comment.
await createComment(`${response}${footer({ image: true })}`)
await removeReaction(commentType)
} else if (dirty) {
const summary = await summarize(response)
await pushToNewBranch(summary, branch, uncommittedChanges, false)
const pr = await createPR(
@@ -618,7 +650,11 @@ export const GithubRunCommand = cmd({
summary,
`${response}\n\nCloses #${issueId}${footer({ image: true })}`,
)
await createComment(`Created PR #${pr}${footer({ image: true })}`)
if (pr) {
await createComment(`Created PR #${pr}${footer({ image: true })}`)
} else {
await createComment(`${response}${footer({ image: true })}`)
}
await removeReaction(commentType)
} else {
await createComment(`${response}${footer({ image: true })}`)
@@ -811,6 +847,7 @@ export const GithubRunCommand = cmd({
replacement,
})
}
return { userPrompt: prompt, promptFiles: imgData }
}
@@ -918,10 +955,15 @@ export const GithubRunCommand = cmd({
// result should always be assistant just satisfying type checker
if (result.info.role === "assistant" && result.info.error) {
console.error("Agent error:", result.info.error)
throw new Error(
`${result.info.error.name}: ${"message" in result.info.error ? result.info.error.message : ""}`,
)
const err = result.info.error
console.error("Agent error:", err)
if (err.name === "ContextOverflowError") {
throw new Error(formatPromptTooLargeError(files))
}
const errorMsg = err.data?.message || ""
throw new Error(`${err.name}: ${errorMsg}`)
}
const text = extractResponseText(result.parts)
@@ -947,10 +989,15 @@ export const GithubRunCommand = cmd({
})
if (summary.info.role === "assistant" && summary.info.error) {
console.error("Summary agent error:", summary.info.error)
throw new Error(
`${summary.info.error.name}: ${"message" in summary.info.error ? summary.info.error.message : ""}`,
)
const err = summary.info.error
console.error("Summary agent error:", err)
if (err.name === "ContextOverflowError") {
throw new Error(formatPromptTooLargeError(files))
}
const errorMsg = err.data?.message || ""
throw new Error(`${err.name}: ${errorMsg}`)
}
const summaryText = extractResponseText(summary.parts)
@@ -1056,6 +1103,7 @@ export const GithubRunCommand = cmd({
await $`git remote add fork https://github.com/${pr.headRepository.nameWithOwner}.git`
await $`git fetch fork --depth=${depth} ${remoteBranch}`
await $`git checkout -b ${localBranch} fork/${remoteBranch}`
return localBranch
}
function generateBranchName(type: "issue" | "pr" | "schedule" | "dispatch") {
@@ -1113,23 +1161,44 @@ Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"`
await $`git push fork HEAD:${remoteBranch}`
}
async function branchIsDirty(originalHead: string) {
async function branchIsDirty(originalHead: string, expectedBranch: string) {
console.log("Checking if branch is dirty...")
// Detect if the agent switched branches during chat (e.g. created
// its own branch, committed, and possibly pushed/created a PR).
const current = (await $`git rev-parse --abbrev-ref HEAD`).stdout.toString().trim()
if (current !== expectedBranch) {
console.log(`Branch changed during chat: expected ${expectedBranch}, now on ${current}`)
return { dirty: true, uncommittedChanges: false, switched: true }
}
const ret = await $`git status --porcelain`
const status = ret.stdout.toString().trim()
if (status.length > 0) {
return {
dirty: true,
uncommittedChanges: true,
}
return { dirty: true, uncommittedChanges: true, switched: false }
}
const head = await $`git rev-parse HEAD`
const head = (await $`git rev-parse HEAD`).stdout.toString().trim()
return {
dirty: head.stdout.toString().trim() !== originalHead,
dirty: head !== originalHead,
uncommittedChanges: false,
switched: false,
}
}
// Verify commits exist between base ref and a branch using rev-list.
// Falls back to fetching from origin when local refs are missing
// (common in shallow clones from actions/checkout).
async function hasNewCommits(base: string, head: string) {
const result = await $`git rev-list --count ${base}..${head}`.nothrow()
if (result.exitCode !== 0) {
console.log(`rev-list failed, fetching origin/${base}...`)
await $`git fetch origin ${base} --depth=1`.nothrow()
const retry = await $`git rev-list --count origin/${base}..${head}`.nothrow()
if (retry.exitCode !== 0) return true // assume dirty if we can't tell
return parseInt(retry.stdout.toString().trim()) > 0
}
return parseInt(result.stdout.toString().trim()) > 0
}
async function assertPermissions() {
// Only called for non-schedule events, so actor is defined
console.log(`Asserting permissions for user ${actor}...`)
@@ -1249,7 +1318,7 @@ Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"`
})
}
async function createPR(base: string, branch: string, title: string, body: string) {
async function createPR(base: string, branch: string, title: string, body: string): Promise<number | null> {
console.log("Creating pull request...")
// Check if an open PR already exists for this head→base combination
@@ -1274,17 +1343,36 @@ Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"`
console.log(`Failed to check for existing PR: ${e}`)
}
const pr = await withRetry(() =>
octoRest.rest.pulls.create({
owner,
repo,
head: branch,
base,
title,
body,
}),
)
return pr.data.number
// Verify there are commits between base and head before creating the PR.
// In shallow clones, the branch can appear dirty but share the same
// commit as the base, causing a 422 from GitHub.
if (!(await hasNewCommits(base, branch))) {
console.log(`No commits between ${base} and ${branch}, skipping PR creation`)
return null
}
try {
const pr = await withRetry(() =>
octoRest.rest.pulls.create({
owner,
repo,
head: branch,
base,
title,
body,
}),
)
return pr.data.number
} catch (e: unknown) {
// Handle "No commits between X and Y" validation error from GitHub.
// This can happen when the branch was pushed but has no new commits
// relative to the base (e.g. shallow clone edge cases).
if (e instanceof Error && e.message.includes("No commits between")) {
console.log(`GitHub rejected PR: ${e.message}`)
return null
}
throw e
}
}
async function withRetry<T>(fn: () => Promise<T>, retries = 1, delayMs = 5000): Promise<T> {
+2 -2
View File
@@ -8,6 +8,7 @@ import { SessionTable, MessageTable, PartTable } from "../../session/session.sql
import { Instance } from "../../project/instance"
import { ShareNext } from "../../share/share-next"
import { EOL } from "os"
import { Filesystem } from "../../util/filesystem"
/** Discriminated union returned by the ShareNext API (GET /api/share/:id/data) */
export type ShareData =
@@ -116,8 +117,7 @@ export const ImportCommand = cmd({
exportData = transformed
} else {
const file = Bun.file(args.file)
exportData = await file.json().catch(() => {})
exportData = await Filesystem.readJson<NonNullable<typeof exportData>>(args.file).catch(() => undefined)
if (!exportData) {
process.stdout.write(`File not found: ${args.file}`)
process.stdout.write(EOL)
+5 -6
View File
@@ -13,6 +13,7 @@ import { Installation } from "../../installation"
import path from "path"
import { Global } from "../../global"
import { modify, applyEdits } from "jsonc-parser"
import { Filesystem } from "../../util/filesystem"
import { Bus } from "../../bus"
function getAuthStatusIcon(status: MCP.AuthStatus): string {
@@ -388,7 +389,7 @@ async function resolveConfigPath(baseDir: string, global = false) {
}
for (const candidate of candidates) {
if (await Bun.file(candidate).exists()) {
if (await Filesystem.exists(candidate)) {
return candidate
}
}
@@ -398,11 +399,9 @@ async function resolveConfigPath(baseDir: string, global = false) {
}
async function addMcpToConfig(name: string, mcpConfig: Config.Mcp, configPath: string) {
const file = Bun.file(configPath)
let text = "{}"
if (await file.exists()) {
text = await file.text()
if (await Filesystem.exists(configPath)) {
text = await Filesystem.readText(configPath)
}
// Use jsonc-parser to modify while preserving comments
@@ -411,7 +410,7 @@ async function addMcpToConfig(name: string, mcpConfig: Config.Mcp, configPath: s
})
const result = applyEdits(text, edits)
await Bun.write(configPath, result)
await Filesystem.write(configPath, result)
return configPath
}
+22 -15
View File
@@ -6,6 +6,7 @@ import { cmd } from "./cmd"
import { Flag } from "../../flag/flag"
import { bootstrap } from "../bootstrap"
import { EOL } from "os"
import { Filesystem } from "../../util/filesystem"
import { createOpencodeClient, type Message, type OpencodeClient, type ToolPart } from "@kilocode/sdk/v2"
import { Server } from "../../server/server"
import { Provider } from "../../provider/provider"
@@ -167,12 +168,17 @@ function websearch(info: ToolProps<typeof WebSearchTool>) {
}
function task(info: ToolProps<typeof TaskTool>) {
const agent = Locale.titlecase(info.input.subagent_type)
const desc = info.input.description
const started = info.part.state.status === "running"
const input = info.part.state.input
const status = info.part.state.status
const subagent =
typeof input.subagent_type === "string" && input.subagent_type.trim().length > 0 ? input.subagent_type : "unknown"
const agent = Locale.titlecase(subagent)
const desc =
typeof input.description === "string" && input.description.trim().length > 0 ? input.description : undefined
const icon = status === "error" ? "✗" : status === "running" ? "•" : "✓"
const name = desc ?? `${agent} Task`
inline({
icon: started ? "•" : "✓",
icon,
title: name,
description: desc ? `${agent} Agent` : undefined,
})
@@ -324,19 +330,12 @@ export const RunCommand = cmd({
for (const filePath of list) {
const resolvedPath = path.resolve(process.cwd(), filePath)
const file = Bun.file(resolvedPath)
const stats = await file.stat().catch(() => {})
if (!stats) {
UI.error(`File not found: ${filePath}`)
process.exit(1)
}
if (!(await file.exists())) {
if (!(await Filesystem.exists(resolvedPath))) {
UI.error(`File not found: ${filePath}`)
process.exit(1)
}
const stat = await file.stat()
const mime = stat.isDirectory() ? "application/x-directory" : "text/plain"
const mime = (await Filesystem.isDir(resolvedPath)) ? "application/x-directory" : "text/plain"
files.push({
type: "file",
@@ -466,9 +465,17 @@ export const RunCommand = cmd({
const part = event.properties.part
if (part.sessionID !== sessionID) continue
if (part.type === "tool" && part.state.status === "completed") {
if (part.type === "tool" && (part.state.status === "completed" || part.state.status === "error")) {
if (emit("tool_use", { part })) continue
tool(part)
if (part.state.status === "completed") {
tool(part)
continue
}
inline({
icon: "✗",
title: `${part.tool} failed`,
})
UI.error(part.state.error)
}
if (
+33 -17
View File
@@ -5,6 +5,7 @@ import { bootstrap } from "../bootstrap"
import { UI } from "../ui"
import { Locale } from "../../util/locale"
import { Flag } from "../../flag/flag"
import { Filesystem } from "../../util/filesystem"
import { EOL } from "os"
import path from "path"
@@ -17,18 +18,18 @@ function pagerCmd(): string[] {
// user could have less installed via other options
const lessOnPath = Bun.which("less")
if (lessOnPath) {
if (Bun.file(lessOnPath).size) return [lessOnPath, ...lessOptions]
if (Filesystem.stat(lessOnPath)?.size) return [lessOnPath, ...lessOptions]
}
if (Flag.KILO_GIT_BASH_PATH) {
const less = path.join(Flag.KILO_GIT_BASH_PATH, "..", "..", "usr", "bin", "less.exe")
if (Bun.file(less).size) return [less, ...lessOptions]
if (Filesystem.stat(less)?.size) return [less, ...lessOptions]
}
const git = Bun.which("git")
if (git) {
const less = path.join(git, "..", "..", "usr", "bin", "less.exe")
if (Bun.file(less).size) return [less, ...lessOptions]
if (Filesystem.stat(less)?.size) return [less, ...lessOptions]
}
// Fall back to Windows built-in more (via cmd.exe)
@@ -38,10 +39,34 @@ function pagerCmd(): string[] {
export const SessionCommand = cmd({
command: "session",
describe: "manage sessions",
builder: (yargs: Argv) => yargs.command(SessionListCommand).demandCommand(),
builder: (yargs: Argv) => yargs.command(SessionListCommand).command(SessionDeleteCommand).demandCommand(),
async handler() {},
})
export const SessionDeleteCommand = cmd({
command: "delete <sessionID>",
describe: "delete a session",
builder: (yargs: Argv) => {
return yargs.positional("sessionID", {
describe: "session ID to delete",
type: "string",
demandOption: true,
})
},
handler: async (args) => {
await bootstrap(process.cwd(), async () => {
try {
await Session.get(args.sessionID)
} catch {
UI.error(`Session not found: ${args.sessionID}`)
process.exit(1)
}
await Session.remove(args.sessionID)
UI.println(UI.Style.TEXT_SUCCESS_BOLD + `Session ${args.sessionID} deleted` + UI.Style.TEXT_NORMAL)
})
},
})
export const SessionListCommand = cmd({
command: "list",
describe: "list sessions",
@@ -61,26 +86,17 @@ export const SessionListCommand = cmd({
},
handler: async (args) => {
await bootstrap(process.cwd(), async () => {
const sessions = []
for await (const session of Session.list()) {
if (!session.parentID) {
sessions.push(session)
}
}
const sessions = [...Session.list({ roots: true, limit: args.maxCount })]
sessions.sort((a, b) => b.time.updated - a.time.updated)
const limitedSessions = args.maxCount ? sessions.slice(0, args.maxCount) : sessions
if (limitedSessions.length === 0) {
if (sessions.length === 0) {
return
}
let output: string
if (args.format === "json") {
output = formatSessionJSON(limitedSessions)
output = formatSessionJSON(sessions)
} else {
output = formatSessionTable(limitedSessions)
output = formatSessionTable(sessions)
}
const shouldPaginate = process.stdout.isTTY && !args.maxCount && args.format === "table"
@@ -1,9 +1,10 @@
import path from "path"
import { Global } from "@/global"
import { Filesystem } from "@/util/filesystem"
import { onMount } from "solid-js"
import { createStore } from "solid-js/store"
import { createSimpleContext } from "../../context/helper"
import { appendFile } from "fs/promises"
import { appendFile, writeFile } from "fs/promises"
function calculateFrecency(entry?: { frequency: number; lastOpen: number }): number {
if (!entry) return 0
@@ -17,9 +18,9 @@ const MAX_FRECENCY_ENTRIES = 1000
export const { use: useFrecency, provider: FrecencyProvider } = createSimpleContext({
name: "Frecency",
init: () => {
const frecencyFile = Bun.file(path.join(Global.Path.state, "frecency.jsonl"))
const frecencyPath = path.join(Global.Path.state, "frecency.jsonl")
onMount(async () => {
const text = await frecencyFile.text().catch(() => "")
const text = await Filesystem.readText(frecencyPath).catch(() => "")
const lines = text
.split("\n")
.filter(Boolean)
@@ -53,7 +54,7 @@ export const { use: useFrecency, provider: FrecencyProvider } = createSimpleCont
if (sorted.length > 0) {
const content = sorted.map((entry) => JSON.stringify(entry)).join("\n") + "\n"
Bun.write(frecencyFile, content).catch(() => {})
writeFile(frecencyPath, content).catch(() => {})
}
})
@@ -68,7 +69,7 @@ export const { use: useFrecency, provider: FrecencyProvider } = createSimpleCont
lastOpen: Date.now(),
}
setStore("data", absolutePath, newEntry)
appendFile(frecencyFile.name!, JSON.stringify({ path: absolutePath, ...newEntry }) + "\n").catch(() => {})
appendFile(frecencyPath, JSON.stringify({ path: absolutePath, ...newEntry }) + "\n").catch(() => {})
if (Object.keys(store.data).length > MAX_FRECENCY_ENTRIES) {
const sorted = Object.entries(store.data)
@@ -76,7 +77,7 @@ export const { use: useFrecency, provider: FrecencyProvider } = createSimpleCont
.slice(0, MAX_FRECENCY_ENTRIES)
setStore("data", Object.fromEntries(sorted))
const content = sorted.map(([path, entry]) => JSON.stringify({ path, ...entry })).join("\n") + "\n"
Bun.write(frecencyFile, content).catch(() => {})
writeFile(frecencyPath, content).catch(() => {})
}
}
@@ -1,5 +1,6 @@
import path from "path"
import { Global } from "@/global"
import { Filesystem } from "@/util/filesystem"
import { onMount } from "solid-js"
import { createStore, produce } from "solid-js/store"
import { clone } from "remeda"
@@ -30,9 +31,9 @@ const MAX_HISTORY_ENTRIES = 50
export const { use: usePromptHistory, provider: PromptHistoryProvider } = createSimpleContext({
name: "PromptHistory",
init: () => {
const historyFile = Bun.file(path.join(Global.Path.state, "prompt-history.jsonl"))
const historyPath = path.join(Global.Path.state, "prompt-history.jsonl")
onMount(async () => {
const text = await historyFile.text().catch(() => "")
const text = await Filesystem.readText(historyPath).catch(() => "")
const lines = text
.split("\n")
.filter(Boolean)
@@ -51,7 +52,7 @@ export const { use: usePromptHistory, provider: PromptHistoryProvider } = create
// Rewrite file with only valid entries to self-heal corruption
if (lines.length > 0) {
const content = lines.map((line) => JSON.stringify(line)).join("\n") + "\n"
writeFile(historyFile.name!, content).catch(() => {})
writeFile(historyPath, content).catch(() => {})
}
})
@@ -97,11 +98,11 @@ export const { use: usePromptHistory, provider: PromptHistoryProvider } = create
if (trimmed) {
const content = store.history.map((line) => JSON.stringify(line)).join("\n") + "\n"
writeFile(historyFile.name!, content).catch(() => {})
writeFile(historyPath, content).catch(() => {})
return
}
appendFile(historyFile.name!, JSON.stringify(entry) + "\n").catch(() => {})
appendFile(historyPath, JSON.stringify(entry) + "\n").catch(() => {})
},
}
},
@@ -1,6 +1,8 @@
import { BoxRenderable, TextareaRenderable, MouseEvent, PasteEvent, t, dim, fg } from "@opentui/core"
import { createEffect, createMemo, type JSX, onMount, createSignal, onCleanup, on, Show, Switch, Match } from "solid-js"
import "opentui-spinner/solid"
import path from "path"
import { Filesystem } from "@/util/filesystem"
import { useLocal } from "@tui/context/local"
import { useTheme } from "@tui/context/theme"
import { EmptyBorder } from "@tui/component/border"
@@ -696,7 +698,7 @@ export function Prompt(props: PromptProps) {
async function pasteImage(file: { filename?: string; content: string; mime: string }) {
const currentOffset = input.visualCursor.offset
const extmarkStart = currentOffset
const count = store.prompt.parts.filter((x) => x.type === "file").length
const count = store.prompt.parts.filter((x) => x.type === "file" && x.mime.startsWith("image/")).length
const virtualText = `[Image ${count + 1}]`
const extmarkEnd = extmarkStart + virtualText.length
const textToInsert = virtualText + " "
@@ -948,26 +950,26 @@ export function Prompt(props: PromptProps) {
const isUrl = /^(https?):\/\//.test(filepath)
if (!isUrl) {
try {
const file = Bun.file(filepath)
const mime = Filesystem.mimeType(filepath)
const filename = path.basename(filepath)
// Handle SVG as raw text content, not as base64 image
if (file.type === "image/svg+xml") {
if (mime === "image/svg+xml") {
event.preventDefault()
const content = await file.text().catch(() => {})
const content = await Filesystem.readText(filepath).catch(() => {})
if (content) {
pasteText(content, `[SVG: ${file.name ?? "image"}]`)
pasteText(content, `[SVG: ${filename ?? "image"}]`)
return
}
}
if (file.type.startsWith("image/")) {
if (mime.startsWith("image/")) {
event.preventDefault()
const content = await file
.arrayBuffer()
const content = await Filesystem.readArrayBuffer(filepath)
.then((buffer) => Buffer.from(buffer).toString("base64"))
.catch(() => {})
if (content) {
await pasteImage({
filename: file.name,
mime: file.type,
filename,
mime,
content,
})
return
@@ -1,5 +1,6 @@
import path from "path"
import { Global } from "@/global"
import { Filesystem } from "@/util/filesystem"
import { onMount } from "solid-js"
import { createStore, produce } from "solid-js/store"
import { clone } from "remeda"
@@ -18,9 +19,9 @@ const MAX_STASH_ENTRIES = 50
export const { use: usePromptStash, provider: PromptStashProvider } = createSimpleContext({
name: "PromptStash",
init: () => {
const stashFile = Bun.file(path.join(Global.Path.state, "prompt-stash.jsonl"))
const stashPath = path.join(Global.Path.state, "prompt-stash.jsonl")
onMount(async () => {
const text = await stashFile.text().catch(() => "")
const text = await Filesystem.readText(stashPath).catch(() => "")
const lines = text
.split("\n")
.filter(Boolean)
@@ -39,7 +40,7 @@ export const { use: usePromptStash, provider: PromptStashProvider } = createSimp
// Rewrite file with only valid entries to self-heal corruption
if (lines.length > 0) {
const content = lines.map((line) => JSON.stringify(line)).join("\n") + "\n"
writeFile(stashFile.name!, content).catch(() => {})
writeFile(stashPath, content).catch(() => {})
}
})
@@ -66,11 +67,11 @@ export const { use: usePromptStash, provider: PromptStashProvider } = createSimp
if (trimmed) {
const content = store.entries.map((line) => JSON.stringify(line)).join("\n") + "\n"
writeFile(stashFile.name!, content).catch(() => {})
writeFile(stashPath, content).catch(() => {})
return
}
appendFile(stashFile.name!, JSON.stringify(stash) + "\n").catch(() => {})
appendFile(stashPath, JSON.stringify(stash) + "\n").catch(() => {})
},
pop() {
if (store.entries.length === 0) return undefined
@@ -82,7 +83,7 @@ export const { use: usePromptStash, provider: PromptStashProvider } = createSimp
)
const content =
store.entries.length > 0 ? store.entries.map((line) => JSON.stringify(line)).join("\n") + "\n" : ""
writeFile(stashFile.name!, content).catch(() => {})
writeFile(stashPath, content).catch(() => {})
return entry
},
remove(index: number) {
@@ -94,7 +95,7 @@ export const { use: usePromptStash, provider: PromptStashProvider } = createSimp
)
const content =
store.entries.length > 0 ? store.entries.map((line) => JSON.stringify(line)).join("\n") + "\n" : ""
writeFile(stashFile.name!, content).catch(() => {})
writeFile(stashPath, content).catch(() => {})
},
}
},
@@ -34,7 +34,6 @@ export const { use: useExit, provider: ExitProvider } = createSimpleContext({
renderer.setTerminalTitle("")
renderer.destroy()
win32FlushInputBuffer()
await input.onExit?.()
if (reason) {
const formatted = FormatError(reason) ?? FormatUnknownError(reason)
if (formatted) {
@@ -43,7 +42,7 @@ export const { use: useExit, provider: ExitProvider } = createSimpleContext({
}
const text = store.get()
if (text) process.stdout.write(text + "\n")
process.exit(0)
await input.onExit?.()
},
{
message: store,
@@ -1,4 +1,5 @@
import { Global } from "@/global"
import { Filesystem } from "@/util/filesystem"
import { createSignal, type Setter } from "solid-js"
import { createStore } from "solid-js/store"
import { createSimpleContext } from "./helper"
@@ -9,10 +10,9 @@ export const { use: useKV, provider: KVProvider } = createSimpleContext({
init: () => {
const [ready, setReady] = createSignal(false)
const [store, setStore] = createStore<Record<string, any>>()
const file = Bun.file(path.join(Global.Path.state, "kv.json"))
const filePath = path.join(Global.Path.state, "kv.json")
file
.json()
Filesystem.readJson(filePath)
.then((x) => {
setStore(x)
})
@@ -44,7 +44,7 @@ export const { use: useKV, provider: KVProvider } = createSimpleContext({
},
set(key: string, value: any) {
setStore(key, value)
Bun.write(file, JSON.stringify(store, null, 2))
Filesystem.writeJson(filePath, store)
},
}
return result
@@ -12,6 +12,7 @@ import { Provider } from "@/provider/provider"
import { useArgs } from "./args"
import { useSDK } from "./sdk"
import { RGBA } from "@opentui/core"
import { Filesystem } from "@/util/filesystem"
export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
name: "Local",
@@ -119,7 +120,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
variant: {},
})
const file = Bun.file(path.join(Global.Path.state, "model.json"))
const filePath = path.join(Global.Path.state, "model.json")
const state = {
pending: false,
}
@@ -130,19 +131,15 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
return
}
state.pending = false
Bun.write(
file,
JSON.stringify({
recent: modelStore.recent,
favorite: modelStore.favorite,
variant: modelStore.variant,
}),
)
Filesystem.writeJson(filePath, {
recent: modelStore.recent,
favorite: modelStore.favorite,
variant: modelStore.variant,
})
}
file
.json()
.then((x) => {
Filesystem.readJson(filePath)
.then((x: any) => {
if (Array.isArray(x.recent)) setModelStore("recent", x.recent)
if (Array.isArray(x.favorite)) setModelStore("favorite", x.favorite)
if (typeof x.variant === "object" && x.variant !== null) setModelStore("variant", x.variant)
@@ -3,6 +3,7 @@ import path from "path"
import { createEffect, createMemo, onMount } from "solid-js"
import { useSync } from "@tui/context/sync"
import { createSimpleContext } from "./helper"
import { Glob } from "../../../../util/glob"
import aura from "./theme/aura.json" with { type: "json" }
import ayu from "./theme/ayu.json" with { type: "json" }
import catppuccin from "./theme/catppuccin.json" with { type: "json" }
@@ -393,7 +394,6 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
},
})
const CUSTOM_THEME_GLOB = new Bun.Glob("themes/*.json")
async function getCustomThemes() {
const directories = [
Global.Path.config,
@@ -407,14 +407,14 @@ async function getCustomThemes() {
const result: Record<string, ThemeJson> = {}
for (const dir of directories) {
for await (const item of CUSTOM_THEME_GLOB.scan({
absolute: true,
followSymlinks: true,
dot: true,
for (const item of await Glob.scan("themes/*.json", {
cwd: dir,
absolute: true,
dot: true,
symlink: true,
})) {
const name = path.basename(item, ".json")
result[name] = await Bun.file(item).json()
result[name] = await Filesystem.readJson(item)
}
}
return result
@@ -64,12 +64,16 @@ function EditBody(props: { request: PermissionRequest }) {
return (
<box flexDirection="column" gap={1}>
<box flexDirection="row" gap={1} paddingLeft={1}>
<text fg={theme.textMuted}>{"→"}</text>
<text fg={theme.textMuted}>Edit {normalizePath(filepath())}</text>
</box>
<Show when={diff()}>
<scrollbox height="100%">
<scrollbox
height="100%"
verticalScrollbarOptions={{
trackOptions: {
backgroundColor: theme.background,
foregroundColor: theme.borderActive,
},
}}
>
<diff
diff={diff()}
view={view()}
@@ -91,6 +95,11 @@ function EditBody(props: { request: PermissionRequest }) {
/>
</scrollbox>
</Show>
<Show when={!diff()}>
<box paddingLeft={1}>
<text fg={theme.textMuted}>No diff provided</text>
</box>
</Show>
</box>
)
}
@@ -196,76 +205,233 @@ export function PermissionPrompt(props: { request: PermissionRequest }) {
</Match>
<Match when={store.stage === "permission"}>
{(() => {
const info = () => {
const permission = props.request.permission
const data = input()
if (permission === "edit") {
const raw = props.request.metadata?.filepath
const filepath = typeof raw === "string" ? raw : ""
return {
icon: "→",
title: `Edit ${normalizePath(filepath)}`,
body: <EditBody request={props.request} />,
}
}
if (permission === "read") {
const raw = data.filePath
const filePath = typeof raw === "string" ? raw : ""
return {
icon: "→",
title: `Read ${normalizePath(filePath)}`,
body: (
<Show when={filePath}>
<box paddingLeft={1}>
<text fg={theme.textMuted}>{"Path: " + normalizePath(filePath)}</text>
</box>
</Show>
),
}
}
if (permission === "glob") {
const pattern = typeof data.pattern === "string" ? data.pattern : ""
return {
icon: "✱",
title: `Glob "${pattern}"`,
body: (
<Show when={pattern}>
<box paddingLeft={1}>
<text fg={theme.textMuted}>{"Pattern: " + pattern}</text>
</box>
</Show>
),
}
}
if (permission === "grep") {
const pattern = typeof data.pattern === "string" ? data.pattern : ""
return {
icon: "✱",
title: `Grep "${pattern}"`,
body: (
<Show when={pattern}>
<box paddingLeft={1}>
<text fg={theme.textMuted}>{"Pattern: " + pattern}</text>
</box>
</Show>
),
}
}
if (permission === "list") {
const raw = data.path
const dir = typeof raw === "string" ? raw : ""
return {
icon: "→",
title: `List ${normalizePath(dir)}`,
body: (
<Show when={dir}>
<box paddingLeft={1}>
<text fg={theme.textMuted}>{"Path: " + normalizePath(dir)}</text>
</box>
</Show>
),
}
}
if (permission === "bash") {
const title =
typeof data.description === "string" && data.description ? data.description : "Shell command"
const command = typeof data.command === "string" ? data.command : ""
return {
icon: "#",
title,
body: (
<Show when={command}>
<box paddingLeft={1}>
<text fg={theme.text}>{"$ " + command}</text>
</box>
</Show>
),
}
}
if (permission === "task") {
const type = typeof data.subagent_type === "string" ? data.subagent_type : "Unknown"
const desc = typeof data.description === "string" ? data.description : ""
return {
icon: "#",
title: `${Locale.titlecase(type)} Task`,
body: (
<Show when={desc}>
<box paddingLeft={1}>
<text fg={theme.text}>{"◉ " + desc}</text>
</box>
</Show>
),
}
}
if (permission === "webfetch") {
const url = typeof data.url === "string" ? data.url : ""
return {
icon: "%",
title: `WebFetch ${url}`,
body: (
<Show when={url}>
<box paddingLeft={1}>
<text fg={theme.textMuted}>{"URL: " + url}</text>
</box>
</Show>
),
}
}
if (permission === "websearch") {
const query = typeof data.query === "string" ? data.query : ""
return {
icon: "◈",
title: `Exa Web Search "${query}"`,
body: (
<Show when={query}>
<box paddingLeft={1}>
<text fg={theme.textMuted}>{"Query: " + query}</text>
</box>
</Show>
),
}
}
if (permission === "codesearch") {
const query = typeof data.query === "string" ? data.query : ""
return {
icon: "◇",
title: `Exa Code Search "${query}"`,
body: (
<Show when={query}>
<box paddingLeft={1}>
<text fg={theme.textMuted}>{"Query: " + query}</text>
</box>
</Show>
),
}
}
if (permission === "external_directory") {
const meta = props.request.metadata ?? {}
const parent = typeof meta["parentDir"] === "string" ? meta["parentDir"] : undefined
const filepath = typeof meta["filepath"] === "string" ? meta["filepath"] : undefined
const pattern = props.request.patterns?.[0]
const derived =
typeof pattern === "string" ? (pattern.includes("*") ? path.dirname(pattern) : pattern) : undefined
const raw = parent ?? filepath ?? derived
const dir = normalizePath(raw)
const patterns = (props.request.patterns ?? []).filter((p): p is string => typeof p === "string")
return {
icon: "←",
title: `Access external directory ${dir}`,
body: (
<Show when={patterns.length > 0}>
<box paddingLeft={1} gap={1}>
<text fg={theme.textMuted}>Patterns</text>
<box>
<For each={patterns}>{(p) => <text fg={theme.text}>{"- " + p}</text>}</For>
</box>
</box>
</Show>
),
}
}
if (permission === "doom_loop") {
return {
icon: "⟳",
title: "Continue after repeated failures",
body: (
<box paddingLeft={1}>
<text fg={theme.textMuted}>This keeps the session running despite repeated failures.</text>
</box>
),
}
}
return {
icon: "⚙",
title: `Call tool ${permission}`,
body: (
<box paddingLeft={1}>
<text fg={theme.textMuted}>{"Tool: " + permission}</text>
</box>
),
}
}
const current = info()
const header = () => (
<box flexDirection="column" gap={0}>
<box flexDirection="row" gap={1} flexShrink={0}>
<text fg={theme.warning}>{"△"}</text>
<text fg={theme.text}>Permission required</text>
</box>
<box flexDirection="row" gap={1} paddingLeft={2} flexShrink={0}>
<text fg={theme.textMuted} flexShrink={0}>
{current.icon}
</text>
<text fg={theme.text}>{current.title}</text>
</box>
</box>
)
const body = (
<Prompt
title="Permission required"
body={
<Switch>
<Match when={props.request.permission === "edit"}>
<EditBody request={props.request} />
</Match>
<Match when={props.request.permission === "read"}>
<TextBody icon="→" title={`Read ` + normalizePath(input().filePath as string)} />
</Match>
<Match when={props.request.permission === "glob"}>
<TextBody icon="✱" title={`Glob "` + (input().pattern ?? "") + `"`} />
</Match>
<Match when={props.request.permission === "grep"}>
<TextBody icon="✱" title={`Grep "` + (input().pattern ?? "") + `"`} />
</Match>
<Match when={props.request.permission === "list"}>
<TextBody icon="→" title={`List ` + normalizePath(input().path as string)} />
</Match>
<Match when={props.request.permission === "bash"}>
<TextBody
icon="#"
title={(input().description as string) ?? ""}
description={("$ " + input().command) as string}
/>
</Match>
<Match when={props.request.permission === "task"}>
<TextBody
icon="#"
title={`${Locale.titlecase((input().subagent_type as string) ?? "Unknown")} Task`}
description={"◉ " + input().description}
/>
</Match>
<Match when={props.request.permission === "webfetch"}>
<TextBody icon="%" title={`WebFetch ` + (input().url ?? "")} />
</Match>
<Match when={props.request.permission === "websearch"}>
<TextBody icon="◈" title={`Exa Web Search "` + (input().query ?? "") + `"`} />
</Match>
<Match when={props.request.permission === "codesearch"}>
<TextBody icon="◇" title={`Exa Code Search "` + (input().query ?? "") + `"`} />
</Match>
<Match when={props.request.permission === "external_directory"}>
{(() => {
const meta = props.request.metadata ?? {}
const parent = typeof meta["parentDir"] === "string" ? meta["parentDir"] : undefined
const filepath = typeof meta["filepath"] === "string" ? meta["filepath"] : undefined
const pattern = props.request.patterns?.[0]
const derived =
typeof pattern === "string"
? pattern.includes("*")
? path.dirname(pattern)
: pattern
: undefined
const raw = parent ?? filepath ?? derived
const dir = normalizePath(raw)
return <TextBody icon="←" title={`Access external directory ` + dir} />
})()}
</Match>
<Match when={props.request.permission === "doom_loop"}>
<TextBody icon="⟳" title="Continue after repeated failures" />
</Match>
<Match when={true}>
<TextBody icon="⚙" title={`Call tool ` + props.request.permission} />
</Match>
</Switch>
}
header={header()}
body={current.body}
options={{ once: "Allow once", always: "Allow always", reject: "Reject" }}
escapeKey="reject"
fullscreen
@@ -375,6 +541,7 @@ function RejectPrompt(props: { onConfirm: (message: string) => void; onCancel: (
function Prompt<const T extends Record<string, string>>(props: {
title: string
header?: JSX.Element
body: JSX.Element
options: T
escapeKey?: keyof T
@@ -448,10 +615,19 @@ function Prompt<const T extends Record<string, string>>(props: {
})}
>
<box gap={1} paddingLeft={1} paddingRight={3} paddingTop={1} paddingBottom={1} flexGrow={1}>
<box flexDirection="row" gap={1} paddingLeft={1} flexShrink={0}>
<text fg={theme.warning}>{"△"}</text>
<text fg={theme.text}>{props.title}</text>
</box>
<Show
when={props.header}
fallback={
<box flexDirection="row" gap={1} paddingLeft={1} flexShrink={0}>
<text fg={theme.warning}>{"△"}</text>
<text fg={theme.text}>{props.title}</text>
</box>
}
>
<box paddingLeft={1} flexShrink={0}>
{props.header}
</box>
</Show>
{props.body}
</box>
<box
@@ -80,7 +80,15 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
paddingRight={2}
position={props.overlay ? "absolute" : "relative"}
>
<scrollbox flexGrow={1}>
<scrollbox
flexGrow={1}
verticalScrollbarOptions={{
trackOptions: {
backgroundColor: theme.background,
foregroundColor: theme.borderActive,
},
}}
>
<box flexShrink={0} gap={1} paddingRight={1}>
<box paddingRight={1}>
<text fg={theme.text}>
+4 -1
View File
@@ -3,10 +3,12 @@ import { tui } from "./app"
import { Rpc } from "@/util/rpc"
import { type rpc } from "./worker"
import path from "path"
import { fileURLToPath } from "url"
import { UI } from "@/cli/ui"
import { iife } from "@/util/iife"
import { Log } from "@/util/log"
import { withNetworkOptions, resolveNetworkOptions } from "@/cli/network"
import { Filesystem } from "@/util/filesystem"
import type { Event } from "@kilocode/sdk/v2"
import type { EventSource } from "./context/sdk"
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
@@ -99,7 +101,7 @@ export const TuiThreadCommand = cmd({
const distWorker = new URL("./cli/cmd/tui/worker.js", import.meta.url)
const workerPath = await iife(async () => {
if (typeof KILO_WORKER_PATH !== "undefined") return KILO_WORKER_PATH
if (await Bun.file(distWorker).exists()) return distWorker
if (await Filesystem.exists(fileURLToPath(distWorker))) return distWorker
return localWorker
})
try {
@@ -191,5 +193,6 @@ export const TuiThreadCommand = cmd({
} finally {
unguard?.()
}
process.exit(0)
},
})
@@ -4,6 +4,7 @@ import clipboardy from "clipboardy"
import { lazy } from "../../../../util/lazy.js"
import { tmpdir } from "os"
import path from "path"
import { Filesystem } from "../../../../util/filesystem"
/**
* Writes text to clipboard via OSC 52 escape sequence.
@@ -34,9 +35,8 @@ export namespace Clipboard {
await $`osascript -e 'set imageData to the clipboard as "PNGf"' -e 'set fileRef to open for access POSIX file "${tmpfile}" with write permission' -e 'set eof fileRef to 0' -e 'write imageData to fileRef' -e 'close access fileRef'`
.nothrow()
.quiet()
const file = Bun.file(tmpfile)
const buffer = await file.arrayBuffer()
return { data: Buffer.from(buffer).toString("base64"), mime: "image/png" }
const buffer = await Filesystem.readBytes(tmpfile)
return { data: buffer.toString("base64"), mime: "image/png" }
} catch {
} finally {
await $`rm -f "${tmpfile}"`.nothrow().quiet()
@@ -3,6 +3,7 @@ import { rm } from "node:fs/promises"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { CliRenderer } from "@opentui/core"
import { Filesystem } from "@/util/filesystem"
export namespace Editor {
export async function open(opts: { value: string; renderer: CliRenderer }): Promise<string | undefined> {
@@ -12,7 +13,7 @@ export namespace Editor {
const filepath = join(tmpdir(), `${Date.now()}.md`)
await using _ = defer(async () => rm(filepath, { force: true }))
await Bun.write(filepath, opts.value)
await Filesystem.write(filepath, opts.value)
opts.renderer.suspend()
opts.renderer.currentRenderBuffer.clear()
const parts = editor.split(" ")
@@ -23,7 +24,7 @@ export namespace Editor {
stderr: "inherit",
})
await proc.exited
const content = await Bun.file(filepath).text()
const content = await Filesystem.readText(filepath)
opts.renderer.currentRenderBuffer.clear()
opts.renderer.resume()
opts.renderer.requestRender()
+6 -1
View File
@@ -137,7 +137,12 @@ export const rpc = {
async shutdown() {
Log.Default.info("worker shutting down")
if (eventStream.abort) eventStream.abort.abort()
await Instance.disposeAll()
await Promise.race([
Instance.disposeAll(),
new Promise((resolve) => {
setTimeout(resolve, 5000)
}),
])
if (server) server.stop(true)
},
}
+4 -5
View File
@@ -7,6 +7,7 @@ import { $ } from "bun"
import fs from "fs/promises"
import path from "path"
import os from "os"
import { Filesystem } from "../../util/filesystem"
interface UninstallArgs {
keepConfig: boolean
@@ -268,9 +269,7 @@ async function getShellConfigFile(): Promise<string | null> {
.catch(() => false)
if (!exists) continue
const content = await Bun.file(file)
.text()
.catch(() => "")
const content = await Filesystem.readText(file).catch(() => "")
// kilocode_change start - detect both opencode and kilo markers
if (
content.includes("# opencode") ||
@@ -287,7 +286,7 @@ async function getShellConfigFile(): Promise<string | null> {
}
async function cleanShellConfig(file: string) {
const content = await Bun.file(file).text()
const content = await Filesystem.readText(file)
const lines = content.split("\n")
const filtered: string[] = []
@@ -325,7 +324,7 @@ async function cleanShellConfig(file: string) {
}
const output = filtered.join("\n") + "\n"
await Bun.write(file, output)
await Filesystem.write(file, output)
}
async function getDirectorySize(dir: string): Promise<number> {
+3
View File
@@ -106,6 +106,9 @@ export namespace UI {
}
export function error(message: string) {
if (message.startsWith("Error: ")) {
message = message.slice("Error: ".length)
}
println(Style.TEXT_DANGER_BOLD + "Error: " + Style.TEXT_NORMAL + message)
}
+68 -62
View File
@@ -28,6 +28,7 @@ import { constants, existsSync } from "fs"
import { Bus } from "@/bus"
import { GlobalBus } from "@/bus/global"
import { Event } from "../server/event"
import { Glob } from "../util/glob"
import { PackageRegistry } from "@/bun/registry"
import { proxied } from "@/util/proxied"
import { iife } from "@/util/iife"
@@ -188,7 +189,13 @@ export namespace Config {
const remoteConfig = wellknown.config ?? {}
// Add $schema to prevent load() from trying to write back to a non-existent file
if (!remoteConfig.$schema) remoteConfig.$schema = "https://kilo.ai/config.json" // kilocode_change
result = merge(result, await load(JSON.stringify(remoteConfig), `${key}/.well-known/opencode`))
result = merge(
result,
await load(JSON.stringify(remoteConfig), {
dir: path.dirname(`${key}/.well-known/opencode`),
source: `${key}/.well-known/opencode`,
}),
)
log.debug("loaded remote config from well-known", { url: key })
}
}
@@ -276,8 +283,14 @@ export namespace Config {
}
// Inline config content overrides all non-managed config sources.
if (Flag.KILO_CONFIG_CONTENT) {
result = merge(result, JSON.parse(Flag.KILO_CONFIG_CONTENT))
if (process.env.KILO_CONFIG_CONTENT) {
result = merge(
result,
await load(process.env.KILO_CONFIG_CONTENT, {
dir: Instance.directory,
source: "KILO_CONFIG_CONTENT",
}),
)
log.debug("loaded custom config from KILO_CONFIG_CONTENT")
}
@@ -354,19 +367,20 @@ export namespace Config {
const pkg = path.join(dir, "package.json")
const targetVersion = Installation.isLocal() ? "*" : Installation.VERSION
const json = await Bun.file(pkg)
.json()
.catch(() => ({}))
const json = await Filesystem.readJson<{ dependencies?: Record<string, string> }>(pkg).catch(() => ({
dependencies: {},
}))
json.dependencies = {
...json.dependencies,
"@opencode-ai/plugin": targetVersion,
}
await Bun.write(pkg, JSON.stringify(json, null, 2))
await Filesystem.writeJson(pkg, json)
await new Promise((resolve) => setTimeout(resolve, 3000))
const gitignore = path.join(dir, ".gitignore")
const hasGitIgnore = await Bun.file(gitignore).exists()
if (!hasGitIgnore) await Bun.write(gitignore, ["node_modules", "package.json", "bun.lock", ".gitignore"].join("\n"))
const hasGitIgnore = await Filesystem.exists(gitignore)
if (!hasGitIgnore)
await Filesystem.write(gitignore, ["node_modules", "package.json", "bun.lock", ".gitignore"].join("\n"))
// Install any additional dependencies defined in the package.json
// This allows local plugins and custom tools to use external packages
@@ -402,11 +416,10 @@ export namespace Config {
if (!existsSync(nodeModules)) return true
const pkg = path.join(dir, "package.json")
const pkgFile = Bun.file(pkg)
const pkgExists = await pkgFile.exists()
const pkgExists = await Filesystem.exists(pkg)
if (!pkgExists) return true
const parsed = await pkgFile.json().catch(() => null)
const parsed = await Filesystem.readJson<{ dependencies?: Record<string, string> }>(pkg).catch(() => null)
const dependencies = parsed?.dependencies ?? {}
const depVersion = dependencies["@opencode-ai/plugin"]
if (!depVersion) return true
@@ -438,14 +451,13 @@ export namespace Config {
return ext.length ? file.slice(0, -ext.length) : file
}
const COMMAND_GLOB = new Bun.Glob("{command,commands}/**/*.md")
async function loadCommand(dir: string) {
const result: Record<string, Command> = {}
for await (const item of COMMAND_GLOB.scan({
absolute: true,
followSymlinks: true,
dot: true,
for (const item of await Glob.scan("{command,commands}/**/*.md", {
cwd: dir,
absolute: true,
dot: true,
symlink: true,
})) {
const md = await ConfigMarkdown.parse(item).catch(async (err) => {
const message = ConfigMarkdown.FrontmatterError.isInstance(err)
@@ -477,15 +489,14 @@ export namespace Config {
return result
}
const AGENT_GLOB = new Bun.Glob("{agent,agents}/**/*.md")
async function loadAgent(dir: string) {
const result: Record<string, Agent> = {}
for await (const item of AGENT_GLOB.scan({
absolute: true,
followSymlinks: true,
dot: true,
for (const item of await Glob.scan("{agent,agents}/**/*.md", {
cwd: dir,
absolute: true,
dot: true,
symlink: true,
})) {
const md = await ConfigMarkdown.parse(item).catch(async (err) => {
const message = ConfigMarkdown.FrontmatterError.isInstance(err)
@@ -517,14 +528,13 @@ export namespace Config {
return result
}
const MODE_GLOB = new Bun.Glob("{mode,modes}/*.md")
async function loadMode(dir: string) {
const result: Record<string, Agent> = {}
for await (const item of MODE_GLOB.scan({
absolute: true,
followSymlinks: true,
dot: true,
for (const item of await Glob.scan("{mode,modes}/*.md", {
cwd: dir,
absolute: true,
dot: true,
symlink: true,
})) {
const md = await ConfigMarkdown.parse(item).catch(async (err) => {
const message = ConfigMarkdown.FrontmatterError.isInstance(err)
@@ -554,15 +564,14 @@ export namespace Config {
return result
}
const PLUGIN_GLOB = new Bun.Glob("{plugin,plugins}/*.{ts,js}")
async function loadPlugin(dir: string) {
const plugins: string[] = []
for await (const item of PLUGIN_GLOB.scan({
absolute: true,
followSymlinks: true,
dot: true,
for (const item of await Glob.scan("{plugin,plugins}/*.{ts,js}", {
cwd: dir,
absolute: true,
dot: true,
symlink: true,
})) {
plugins.push(pathToFileURL(item).href)
}
@@ -1324,7 +1333,7 @@ export namespace Config {
if (provider && model) result.model = `${provider}/${model}`
result["$schema"] = "https://kilo.ai/config.json" // kilocode_change
result = mergeDeep(result, rest)
await Bun.write(path.join(Global.Path.config, "config.json"), JSON.stringify(result, null, 2))
await Filesystem.writeJson(path.join(Global.Path.config, "config.json"), result)
await fs.unlink(legacy)
})
.catch(() => {})
@@ -1335,31 +1344,32 @@ export namespace Config {
async function loadFile(filepath: string): Promise<Info> {
log.info("loading", { path: filepath })
let text = await Bun.file(filepath)
.text()
.catch((err) => {
if (err.code === "ENOENT") return
throw new JsonError({ path: filepath }, { cause: err })
})
let text = await Filesystem.readText(filepath).catch((err: any) => {
if (err.code === "ENOENT") return
throw new JsonError({ path: filepath }, { cause: err })
})
if (!text) return {}
return load(text, filepath)
return load(text, { path: filepath })
}
async function load(text: string, configFilepath: string) {
async function load(text: string, options: { path: string } | { dir: string; source: string }) {
const original = text
const configDir = "path" in options ? path.dirname(options.path) : options.dir
const source = "path" in options ? options.path : options.source
const isFile = "path" in options
text = text.replace(/\{env:([^}]+)\}/g, (_, varName) => {
return process.env[varName] || ""
})
const fileMatches = text.match(/\{file:[^}]+\}/g)
if (fileMatches) {
const configDir = path.dirname(configFilepath)
const lines = text.split("\n")
for (const match of fileMatches) {
const lineIndex = lines.findIndex((line) => line.includes(match))
if (lineIndex !== -1 && lines[lineIndex].trim().startsWith("//")) {
continue // Skip if line is commented
continue
}
let filePath = match.replace(/^\{file:/, "").replace(/\}$/, "")
if (filePath.startsWith("~/")) {
@@ -1374,16 +1384,15 @@ export namespace Config {
if (error.code === "ENOENT") {
throw new InvalidError(
{
path: configFilepath,
path: source,
message: errMsg + ` ${resolvedPath} does not exist`,
},
{ cause: error },
)
}
throw new InvalidError({ path: configFilepath, message: errMsg }, { cause: error })
throw new InvalidError({ path: source, message: errMsg }, { cause: error })
})
).trim()
// escape newlines/quotes, strip outer quotes
text = text.replace(match, () => JSON.stringify(fileContent).slice(1, -1))
}
}
@@ -1407,25 +1416,24 @@ export namespace Config {
.join("\n")
throw new JsonError({
path: configFilepath,
path: source,
message: `\n--- JSONC Input ---\n${text}\n--- Errors ---\n${errorDetails}\n--- End ---`,
})
}
const parsed = Info.safeParse(data)
if (parsed.success) {
if (!parsed.data.$schema) {
if (!parsed.data.$schema && isFile) {
parsed.data.$schema = "https://kilo.ai/config.json" // kilocode_change
// Write the $schema to the original text to preserve variables like {env:VAR}
const updated = original.replace(/^\s*\{/, '{\n "$schema": "https://kilo.ai/config.json",') // kilocode_change
await Bun.write(configFilepath, updated).catch(() => {})
await Bun.write(options.path, updated).catch(() => {})
}
const data = parsed.data
if (data.plugin) {
if (data.plugin && isFile) {
for (let i = 0; i < data.plugin.length; i++) {
const plugin = data.plugin[i]
try {
data.plugin[i] = import.meta.resolve!(plugin, configFilepath)
data.plugin[i] = import.meta.resolve!(plugin, options.path)
} catch (err) {}
}
}
@@ -1433,7 +1441,7 @@ export namespace Config {
}
throw new InvalidError({
path: configFilepath,
path: source,
issues: parsed.error.issues,
})
}
@@ -1474,7 +1482,7 @@ export namespace Config {
export async function update(config: Info) {
const filepath = path.join(Instance.directory, "config.json")
const existing = await loadFile(filepath)
await Bun.write(filepath, JSON.stringify(mergeDeep(existing, config), null, 2))
await Filesystem.writeJson(filepath, mergeDeep(existing, config))
await Instance.dispose()
}
@@ -1545,24 +1553,22 @@ export namespace Config {
export async function updateGlobal(config: Info) {
const filepath = globalConfigFile()
const before = await Bun.file(filepath)
.text()
.catch((err) => {
if (err.code === "ENOENT") return "{}"
throw new JsonError({ path: filepath }, { cause: err })
})
const before = await Filesystem.readText(filepath).catch((err: any) => {
if (err.code === "ENOENT") return "{}"
throw new JsonError({ path: filepath }, { cause: err })
})
const next = await (async () => {
if (!filepath.endsWith(".jsonc")) {
const existing = parseConfig(before, filepath)
const merged = mergeDeep(existing, config)
await Bun.write(filepath, JSON.stringify(merged, null, 2))
await Filesystem.writeJson(filepath, merged)
return merged
}
const updated = patchJsonc(before, config)
const merged = parseConfig(updated, filepath)
await Bun.write(filepath, updated)
await Filesystem.write(filepath, updated)
return merged
})()
+2 -1
View File
@@ -1,6 +1,7 @@
import { NamedError } from "@opencode-ai/util/error"
import matter from "gray-matter"
import { z } from "zod"
import { Filesystem } from "../util/filesystem"
export namespace ConfigMarkdown {
export const FILE_REGEX = /(?<![\w`])@(\.?[^\s`,.]*(?:\.[^\s`,.]+)*)/g
@@ -68,7 +69,7 @@ export namespace ConfigMarkdown {
}
export async function parse(filePath: string) {
const template = await Bun.file(filePath).text()
const template = await Filesystem.readText(filePath)
try {
const md = matter(template)
+7 -8
View File
@@ -1,4 +1,5 @@
import { sep } from "node:path"
import { Glob } from "../util/glob"
export namespace FileIgnore {
const FOLDERS = new Set([
@@ -53,19 +54,17 @@ export namespace FileIgnore {
"**/.nyc_output/**",
]
const FILE_GLOBS = FILES.map((p) => new Bun.Glob(p))
export const PATTERNS = [...FILES, ...FOLDERS]
export function match(
filepath: string,
opts?: {
extra?: Bun.Glob[]
whitelist?: Bun.Glob[]
extra?: string[]
whitelist?: string[]
},
) {
for (const glob of opts?.whitelist || []) {
if (glob.match(filepath)) return false
for (const pattern of opts?.whitelist || []) {
if (Glob.match(pattern, filepath)) return false
}
const parts = filepath.split(sep)
@@ -74,8 +73,8 @@ export namespace FileIgnore {
}
const extra = opts?.extra || []
for (const glob of [...FILE_GLOBS, ...extra]) {
if (glob.match(filepath)) return true
for (const pattern of [...FILES, ...extra]) {
if (Glob.match(pattern, filepath)) return true
}
return false
+94 -31
View File
@@ -1,7 +1,6 @@
import { BusEvent } from "@/bus/bus-event"
import z from "zod"
import { $ } from "bun"
import type { BunFile } from "bun"
import { formatPatch, structuredPatch } from "diff"
import path from "path"
import fs from "fs"
@@ -167,7 +166,6 @@ export namespace File {
"efi",
"rom",
"com",
"bat",
"cmd",
"ps1",
"sh",
@@ -204,11 +202,77 @@ export namespace File {
"x3f",
])
const textExtensions = new Set([
"ts",
"tsx",
"mts",
"cts",
"mtsx",
"ctsx",
"js",
"jsx",
"mjs",
"cjs",
"sh",
"bash",
"zsh",
"fish",
"ps1",
"psm1",
"cmd",
"bat",
"json",
"jsonc",
"json5",
"yaml",
"yml",
"toml",
"md",
"mdx",
"txt",
"xml",
"html",
"htm",
"css",
"scss",
"sass",
"less",
"graphql",
"gql",
"sql",
"ini",
"cfg",
"conf",
"env",
])
const textNames = new Set([
"dockerfile",
"makefile",
".gitignore",
".gitattributes",
".editorconfig",
".npmrc",
".nvmrc",
".prettierrc",
".eslintrc",
])
function isImageByExtension(filepath: string): boolean {
const ext = path.extname(filepath).toLowerCase().slice(1)
return imageExtensions.has(ext)
}
function isTextByExtension(filepath: string): boolean {
const ext = path.extname(filepath).toLowerCase().slice(1)
return textExtensions.has(ext)
}
function isTextByName(filepath: string): boolean {
const name = path.basename(filepath).toLowerCase()
return textNames.has(name)
}
function getImageMimeType(filepath: string): string {
const ext = path.extname(filepath).toLowerCase().slice(1)
const mimeTypes: Record<string, string> = {
@@ -241,8 +305,8 @@ export namespace File {
return mimeType.startsWith("image/")
}
async function shouldEncode(file: BunFile): Promise<boolean> {
const type = file.type?.toLowerCase()
async function shouldEncode(mimeType: string): Promise<boolean> {
const type = mimeType.toLowerCase()
log.info("shouldEncode", { type })
if (!type) return false
@@ -385,7 +449,7 @@ export namespace File {
const untrackedFiles = untrackedOutput.trim().split("\n")
for (const filepath of untrackedFiles) {
try {
const content = await Bun.file(path.join(Instance.directory, filepath)).text()
const content = await Filesystem.readText(path.join(Instance.directory, filepath))
const lines = content.split("\n").length
changedFiles.push({
path: filepath,
@@ -418,10 +482,13 @@ export namespace File {
}
}
return changedFiles.map((x) => ({
...x,
path: path.relative(Instance.directory, x.path),
}))
return changedFiles.map((x) => {
const full = path.isAbsolute(x.path) ? x.path : path.join(Instance.directory, x.path)
return {
...x,
path: path.relative(Instance.directory, full),
}
})
}
export async function read(file: string): Promise<Content> {
@@ -437,43 +504,39 @@ export namespace File {
// Fast path: check extension before any filesystem operations
if (isImageByExtension(file)) {
const bunFile = Bun.file(full)
if (await bunFile.exists()) {
const buffer = await bunFile.arrayBuffer().catch(() => new ArrayBuffer(0))
const content = Buffer.from(buffer).toString("base64")
if (await Filesystem.exists(full)) {
const buffer = await Filesystem.readBytes(full).catch(() => Buffer.from([]))
const content = buffer.toString("base64")
const mimeType = getImageMimeType(file)
return { type: "text", content, mimeType, encoding: "base64" }
}
return { type: "text", content: "" }
}
if (isBinaryByExtension(file)) {
const text = isTextByExtension(file) || isTextByName(file)
if (isBinaryByExtension(file) && !text) {
return { type: "binary", content: "" }
}
const bunFile = Bun.file(full)
if (!(await bunFile.exists())) {
if (!(await Filesystem.exists(full))) {
return { type: "text", content: "" }
}
const encode = await shouldEncode(bunFile)
const mimeType = bunFile.type || "application/octet-stream"
const mimeType = Filesystem.mimeType(full)
const encode = text ? false : await shouldEncode(mimeType)
if (encode && !isImage(mimeType)) {
return { type: "binary", content: "", mimeType }
}
if (encode) {
const buffer = await bunFile.arrayBuffer().catch(() => new ArrayBuffer(0))
const content = Buffer.from(buffer).toString("base64")
const buffer = await Filesystem.readBytes(full).catch(() => Buffer.from([]))
const content = buffer.toString("base64")
return { type: "text", content, mimeType, encoding: "base64" }
}
const content = await bunFile
.text()
.catch(() => "")
.then((x) => x.trim())
const content = (await Filesystem.readText(full).catch(() => "")).trim()
if (project.vcs === "git") {
let diff = await $`git diff ${file}`.cwd(Instance.directory).quiet().nothrow().text()
@@ -497,13 +560,13 @@ export namespace File {
let ignored = (_: string) => false
if (project.vcs === "git") {
const ig = ignore()
const gitignore = Bun.file(path.join(Instance.worktree, ".gitignore"))
if (await gitignore.exists()) {
ig.add(await gitignore.text())
const gitignorePath = path.join(Instance.worktree, ".gitignore")
if (await Filesystem.exists(gitignorePath)) {
ig.add(await Filesystem.readText(gitignorePath))
}
const ignoreFile = Bun.file(path.join(Instance.worktree, ".ignore"))
if (await ignoreFile.exists()) {
ig.add(await ignoreFile.text())
const ignorePath = path.join(Instance.worktree, ".ignore")
if (await Filesystem.exists(ignorePath)) {
ig.add(await Filesystem.readText(ignorePath))
}
ignored = ig.ignores.bind(ig)
}
+6 -6
View File
@@ -6,6 +6,7 @@ import z from "zod"
import { NamedError } from "@opencode-ai/util/error"
import { lazy } from "../util/lazy"
import { $ } from "bun"
import { Filesystem } from "../util/filesystem"
import { ZipReader, BlobReader, BlobWriter } from "@zip.js/zip.js"
import { Log } from "@/util/log"
@@ -131,8 +132,7 @@ export namespace Ripgrep {
}
const filepath = path.join(Global.Path.bin, "rg" + (process.platform === "win32" ? ".exe" : ""))
const file = Bun.file(filepath)
if (!(await file.exists())) {
if (!(await Filesystem.exists(filepath))) {
const platformKey = `${process.arch}-${process.platform}` as keyof typeof PLATFORM
const config = PLATFORM[platformKey]
if (!config) throw new UnsupportedPlatformError({ platform: platformKey })
@@ -144,9 +144,9 @@ export namespace Ripgrep {
const response = await fetch(url)
if (!response.ok) throw new DownloadFailedError({ url, status: response.status })
const buffer = await response.arrayBuffer()
const arrayBuffer = await response.arrayBuffer()
const archivePath = path.join(Global.Path.bin, filename)
await Bun.write(archivePath, buffer)
await Filesystem.write(archivePath, Buffer.from(arrayBuffer))
if (config.extension === "tar.gz") {
const args = ["tar", "-xzf", archivePath, "--strip-components=1"]
@@ -166,7 +166,7 @@ export namespace Ripgrep {
})
}
if (config.extension === "zip") {
const zipFileReader = new ZipReader(new BlobReader(new Blob([await Bun.file(archivePath).arrayBuffer()])))
const zipFileReader = new ZipReader(new BlobReader(new Blob([arrayBuffer])))
const entries = await zipFileReader.getEntries()
let rgEntry: any
for (const entry of entries) {
@@ -190,7 +190,7 @@ export namespace Ripgrep {
stderr: "Failed to extract rg.exe from zip archive",
})
}
await Bun.write(filepath, await rgBlob.arrayBuffer())
await Filesystem.write(filepath, Buffer.from(await rgBlob.arrayBuffer()))
await zipFileReader.close()
}
await fs.unlink(archivePath)
+4 -3
View File
@@ -1,6 +1,7 @@
import { Instance } from "../project/instance"
import { Log } from "../util/log"
import { Flag } from "../flag/flag"
import { Filesystem } from "../util/filesystem"
export namespace FileTime {
const log = Log.create({ service: "file.time" })
@@ -59,10 +60,10 @@ export namespace FileTime {
const time = get(sessionID, filepath)
if (!time) throw new Error(`You must read file ${filepath} before overwriting it. Use the Read tool first`)
const stats = await Bun.file(filepath).stat()
if (stats.mtime.getTime() > time.getTime()) {
const mtime = Filesystem.stat(filepath)?.mtime
if (mtime && mtime.getTime() > time.getTime()) {
throw new Error(
`File ${filepath} has been modified since it was last read.\nLast modification: ${stats.mtime.toISOString()}\nLast read: ${time.toISOString()}\n\nPlease read the file again before modifying it.`,
`File ${filepath} has been modified since it was last read.\nLast modification: ${mtime.toISOString()}\nLast read: ${time.toISOString()}\n\nPlease read the file again before modifying it.`,
)
}
}
+1
View File
@@ -28,6 +28,7 @@ export namespace Flag {
export declare const KILO_CLIENT: string
export const KILO_SERVER_PASSWORD = process.env["KILO_SERVER_PASSWORD"]
export const KILO_SERVER_USERNAME = process.env["KILO_SERVER_USERNAME"]
export const KILO_ENABLE_QUESTION_TOOL = truthy("KILO_ENABLE_QUESTION_TOOL")
// Experimental
export const KILO_EXPERIMENTAL = truthy("KILO_EXPERIMENTAL")
+14 -5
View File
@@ -67,7 +67,10 @@ export const prettier: Info = {
async enabled() {
const items = await Filesystem.findUp("package.json", Instance.directory, Instance.worktree)
for (const item of items) {
const json = await Bun.file(item).json()
const json = await Filesystem.readJson<{
dependencies?: Record<string, string>
devDependencies?: Record<string, string>
}>(item)
if (json.dependencies?.prettier) return true
if (json.devDependencies?.prettier) return true
}
@@ -86,7 +89,10 @@ export const oxfmt: Info = {
if (!Flag.KILO_EXPERIMENTAL_OXFMT) return false
const items = await Filesystem.findUp("package.json", Instance.directory, Instance.worktree)
for (const item of items) {
const json = await Bun.file(item).json()
const json = await Filesystem.readJson<{
dependencies?: Record<string, string>
devDependencies?: Record<string, string>
}>(item)
if (json.dependencies?.oxfmt) return true
if (json.devDependencies?.oxfmt) return true
}
@@ -179,7 +185,7 @@ export const ruff: Info = {
const found = await Filesystem.findUp(config, Instance.directory, Instance.worktree)
if (found.length > 0) {
if (config === "pyproject.toml") {
const content = await Bun.file(found[0]).text()
const content = await Filesystem.readText(found[0])
if (content.includes("[tool.ruff]")) return true
} else {
return true
@@ -190,7 +196,7 @@ export const ruff: Info = {
for (const dep of deps) {
const found = await Filesystem.findUp(dep, Instance.directory, Instance.worktree)
if (found.length > 0) {
const content = await Bun.file(found[0]).text()
const content = await Filesystem.readText(found[0])
if (content.includes("ruff")) return true
}
}
@@ -348,7 +354,10 @@ export const pint: Info = {
async enabled() {
const items = await Filesystem.findUp("composer.json", Instance.directory, Instance.worktree)
for (const item of items) {
const json = await Bun.file(item).json()
const json = await Filesystem.readJson<{
require?: Record<string, string>
"require-dev"?: Record<string, string>
}>(item)
if (json.require?.["laravel/pint"]) return true
if (json["require-dev"]?.["laravel/pint"]) return true
}
+3 -4
View File
@@ -2,6 +2,7 @@ import fs from "fs/promises"
import { xdgData, xdgCache, xdgConfig, xdgState } from "xdg-basedir"
import path from "path"
import os from "os"
import { Filesystem } from "../util/filesystem"
const app = "kilo" // kilocode_change
@@ -35,9 +36,7 @@ await Promise.all([
const CACHE_VERSION = "21"
const version = await Bun.file(path.join(Global.Path.cache, "version"))
.text()
.catch(() => "0")
const version = await Filesystem.readText(path.join(Global.Path.cache, "version")).catch(() => "0")
if (version !== CACHE_VERSION) {
try {
@@ -51,5 +50,5 @@ if (version !== CACHE_VERSION) {
),
)
} catch (e) {}
await Bun.file(path.join(Global.Path.cache, "version")).write(CACHE_VERSION)
await Filesystem.write(path.join(Global.Path.cache, "version"), CACHE_VERSION)
}
+3 -1
View File
@@ -13,6 +13,7 @@ import { Installation } from "./installation"
import { NamedError } from "@opencode-ai/util/error"
import { FormatError } from "./cli/error"
import { ServeCommand } from "./cli/cmd/serve"
import { Filesystem } from "./util/filesystem"
import { DebugCommand } from "./cli/cmd/debug"
import { StatsCommand } from "./cli/cmd/stats"
import { McpCommand } from "./cli/cmd/mcp"
@@ -119,8 +120,9 @@ const cli = yargs(hideBin(process.argv))
Telemetry.trackCliStart()
// kilocode_change end
const marker = path.join(Global.Path.data, "kilo.db")
if (!(await Bun.file(marker).exists())) {
if (!(await Filesystem.exists(marker))) {
const tty = process.stderr.isTTY
process.stderr.write("Performing one time database migration, may take a few minutes..." + EOL)
const width = 36
+1 -2
View File
@@ -147,8 +147,7 @@ export namespace LSPClient {
notify: {
async open(input: { path: string }) {
input.path = path.isAbsolute(input.path) ? input.path : path.resolve(Instance.directory, input.path)
const file = Bun.file(input.path)
const text = await file.text()
const text = await Filesystem.readText(input.path)
const extension = path.extname(input.path)
const languageId = LANGUAGE_EXTENSIONS[extension] ?? "plaintext"
+1
View File
@@ -44,6 +44,7 @@ export const LANGUAGE_EXTENSIONS: Record<string, string> = {
".htm": "html",
".ini": "ini",
".java": "java",
".jl": "julia",
".js": "javascript",
".kt": "kotlin",
".kts": "kotlin",
+62 -51
View File
@@ -131,7 +131,7 @@ export namespace LSPServer {
"bin",
"vue-language-server.js",
)
if (!(await Bun.file(js).exists())) {
if (!(await Filesystem.exists(js))) {
if (Flag.KILO_DISABLE_LSP_DOWNLOAD) return
await Bun.spawn([BunProc.which(), "install", "@vue/language-server"], {
cwd: Global.Path.bin,
@@ -173,14 +173,14 @@ export namespace LSPServer {
if (!eslint) return
log.info("spawning eslint server")
const serverPath = path.join(Global.Path.bin, "vscode-eslint", "server", "out", "eslintServer.js")
if (!(await Bun.file(serverPath).exists())) {
if (!(await Filesystem.exists(serverPath))) {
if (Flag.KILO_DISABLE_LSP_DOWNLOAD) return
log.info("downloading and building VS Code ESLint server")
const response = await fetch("https://github.com/microsoft/vscode-eslint/archive/refs/heads/main.zip")
if (!response.ok) return
const zipPath = path.join(Global.Path.bin, "vscode-eslint.zip")
await Bun.file(zipPath).write(response)
if (response.body) await Filesystem.writeStream(zipPath, response.body)
const ok = await Archive.extractZip(zipPath, Global.Path.bin)
.then(() => true)
@@ -242,7 +242,7 @@ export namespace LSPServer {
const resolveBin = async (target: string) => {
const localBin = path.join(root, target)
if (await Bun.file(localBin).exists()) return localBin
if (await Filesystem.exists(localBin)) return localBin
const candidates = Filesystem.up({
targets: [target],
@@ -326,7 +326,7 @@ export namespace LSPServer {
async spawn(root) {
const localBin = path.join(root, "node_modules", ".bin", "biome")
let bin: string | undefined
if (await Bun.file(localBin).exists()) bin = localBin
if (await Filesystem.exists(localBin)) bin = localBin
if (!bin) {
const found = Bun.which("biome")
if (found) bin = found
@@ -467,7 +467,7 @@ export namespace LSPServer {
const potentialPythonPath = isWindows
? path.join(venvPath, "Scripts", "python.exe")
: path.join(venvPath, "bin", "python")
if (await Bun.file(potentialPythonPath).exists()) {
if (await Filesystem.exists(potentialPythonPath)) {
initialization["pythonPath"] = potentialPythonPath
break
}
@@ -479,7 +479,7 @@ export namespace LSPServer {
const potentialTyPath = isWindows
? path.join(venvPath, "Scripts", "ty.exe")
: path.join(venvPath, "bin", "ty")
if (await Bun.file(potentialTyPath).exists()) {
if (await Filesystem.exists(potentialTyPath)) {
binary = potentialTyPath
break
}
@@ -511,7 +511,7 @@ export namespace LSPServer {
const args = []
if (!binary) {
const js = path.join(Global.Path.bin, "node_modules", "pyright", "dist", "pyright-langserver.js")
if (!(await Bun.file(js).exists())) {
if (!(await Filesystem.exists(js))) {
if (Flag.KILO_DISABLE_LSP_DOWNLOAD) return
await Bun.spawn([BunProc.which(), "install", "pyright"], {
cwd: Global.Path.bin,
@@ -536,7 +536,7 @@ export namespace LSPServer {
const potentialPythonPath = isWindows
? path.join(venvPath, "Scripts", "python.exe")
: path.join(venvPath, "bin", "python")
if (await Bun.file(potentialPythonPath).exists()) {
if (await Filesystem.exists(potentialPythonPath)) {
initialization["pythonPath"] = potentialPythonPath
break
}
@@ -571,7 +571,7 @@ export namespace LSPServer {
process.platform === "win32" ? "language_server.bat" : "language_server.sh",
)
if (!(await Bun.file(binary).exists())) {
if (!(await Filesystem.exists(binary))) {
const elixir = Bun.which("elixir")
if (!elixir) {
log.error("elixir is required to run elixir-ls")
@@ -584,7 +584,7 @@ export namespace LSPServer {
const response = await fetch("https://github.com/elixir-lsp/elixir-ls/archive/refs/heads/master.zip")
if (!response.ok) return
const zipPath = path.join(Global.Path.bin, "elixir-ls.zip")
await Bun.file(zipPath).write(response)
if (response.body) await Filesystem.writeStream(zipPath, response.body)
const ok = await Archive.extractZip(zipPath, Global.Path.bin)
.then(() => true)
@@ -692,7 +692,7 @@ export namespace LSPServer {
}
const tempPath = path.join(Global.Path.bin, assetName)
await Bun.file(tempPath).write(downloadResponse)
if (downloadResponse.body) await Filesystem.writeStream(tempPath, downloadResponse.body)
if (ext === "zip") {
const ok = await Archive.extractZip(tempPath, Global.Path.bin)
@@ -710,7 +710,7 @@ export namespace LSPServer {
bin = path.join(Global.Path.bin, "zls" + (platform === "win32" ? ".exe" : ""))
if (!(await Bun.file(bin).exists())) {
if (!(await Filesystem.exists(bin))) {
log.error("Failed to extract zls binary")
return
}
@@ -857,7 +857,7 @@ export namespace LSPServer {
// Stop at filesystem root
const cargoTomlPath = path.join(currentDir, "Cargo.toml")
try {
const cargoTomlContent = await Bun.file(cargoTomlPath).text()
const cargoTomlContent = await Filesystem.readText(cargoTomlPath)
if (cargoTomlContent.includes("[workspace]")) {
return currentDir
}
@@ -907,7 +907,7 @@ export namespace LSPServer {
const ext = process.platform === "win32" ? ".exe" : ""
const direct = path.join(Global.Path.bin, "clangd" + ext)
if (await Bun.file(direct).exists()) {
if (await Filesystem.exists(direct)) {
return {
process: spawn(direct, args, {
cwd: root,
@@ -920,7 +920,7 @@ export namespace LSPServer {
if (!entry.isDirectory()) continue
if (!entry.name.startsWith("clangd_")) continue
const candidate = path.join(Global.Path.bin, entry.name, "bin", "clangd" + ext)
if (await Bun.file(candidate).exists()) {
if (await Filesystem.exists(candidate)) {
return {
process: spawn(candidate, args, {
cwd: root,
@@ -990,7 +990,7 @@ export namespace LSPServer {
log.error("Failed to write clangd archive")
return
}
await Bun.write(archive, buf)
await Filesystem.write(archive, Buffer.from(buf))
const zip = name.endsWith(".zip")
const tar = name.endsWith(".tar.xz")
@@ -1014,7 +1014,7 @@ export namespace LSPServer {
await fs.rm(archive, { force: true })
const bin = path.join(Global.Path.bin, "clangd_" + tag, "bin", "clangd" + ext)
if (!(await Bun.file(bin).exists())) {
if (!(await Filesystem.exists(bin))) {
log.error("Failed to extract clangd binary")
return
}
@@ -1045,7 +1045,7 @@ export namespace LSPServer {
const args: string[] = []
if (!binary) {
const js = path.join(Global.Path.bin, "node_modules", "svelte-language-server", "bin", "server.js")
if (!(await Bun.file(js).exists())) {
if (!(await Filesystem.exists(js))) {
if (Flag.KILO_DISABLE_LSP_DOWNLOAD) return
await Bun.spawn([BunProc.which(), "install", "svelte-language-server"], {
cwd: Global.Path.bin,
@@ -1092,7 +1092,7 @@ export namespace LSPServer {
const args: string[] = []
if (!binary) {
const js = path.join(Global.Path.bin, "node_modules", "@astrojs", "language-server", "bin", "nodeServer.js")
if (!(await Bun.file(js).exists())) {
if (!(await Filesystem.exists(js))) {
if (Flag.KILO_DISABLE_LSP_DOWNLOAD) return
await Bun.spawn([BunProc.which(), "install", "@astrojs/language-server"], {
cwd: Global.Path.bin,
@@ -1248,7 +1248,7 @@ export namespace LSPServer {
const distPath = path.join(Global.Path.bin, "kotlin-ls")
const launcherScript =
process.platform === "win32" ? path.join(distPath, "kotlin-lsp.cmd") : path.join(distPath, "kotlin-lsp.sh")
const installed = await Bun.file(launcherScript).exists()
const installed = await Filesystem.exists(launcherScript)
if (!installed) {
if (Flag.KILO_DISABLE_LSP_DOWNLOAD) return
log.info("Downloading Kotlin Language Server from GitHub.")
@@ -1307,7 +1307,7 @@ export namespace LSPServer {
}
log.info("Installed Kotlin Language Server", { path: launcherScript })
}
if (!(await Bun.file(launcherScript).exists())) {
if (!(await Filesystem.exists(launcherScript))) {
log.error(`Failed to locate the Kotlin LS launcher script in the installed directory: ${distPath}.`)
return
}
@@ -1336,7 +1336,7 @@ export namespace LSPServer {
"src",
"server.js",
)
const exists = await Bun.file(js).exists()
const exists = await Filesystem.exists(js)
if (!exists) {
if (Flag.KILO_DISABLE_LSP_DOWNLOAD) return
await Bun.spawn([BunProc.which(), "install", "yaml-language-server"], {
@@ -1443,7 +1443,7 @@ export namespace LSPServer {
}
const tempPath = path.join(Global.Path.bin, assetName)
await Bun.file(tempPath).write(downloadResponse)
if (downloadResponse.body) await Filesystem.writeStream(tempPath, downloadResponse.body)
// Unlike zls which is a single self-contained binary,
// lua-language-server needs supporting files (meta/, locale/, etc.)
@@ -1482,7 +1482,7 @@ export namespace LSPServer {
// Binary is located in bin/ subdirectory within the extracted archive
bin = path.join(installDir, "bin", "lua-language-server" + (platform === "win32" ? ".exe" : ""))
if (!(await Bun.file(bin).exists())) {
if (!(await Filesystem.exists(bin))) {
log.error("Failed to extract lua-language-server binary")
return
}
@@ -1516,7 +1516,7 @@ export namespace LSPServer {
const args: string[] = []
if (!binary) {
const js = path.join(Global.Path.bin, "node_modules", "intelephense", "lib", "intelephense.js")
if (!(await Bun.file(js).exists())) {
if (!(await Filesystem.exists(js))) {
if (Flag.KILO_DISABLE_LSP_DOWNLOAD) return
await Bun.spawn([BunProc.which(), "install", "intelephense"], {
cwd: Global.Path.bin,
@@ -1613,7 +1613,7 @@ export namespace LSPServer {
const args: string[] = []
if (!binary) {
const js = path.join(Global.Path.bin, "node_modules", "bash-language-server", "out", "cli.js")
if (!(await Bun.file(js).exists())) {
if (!(await Filesystem.exists(js))) {
if (Flag.KILO_DISABLE_LSP_DOWNLOAD) return
await Bun.spawn([BunProc.which(), "install", "bash-language-server"], {
cwd: Global.Path.bin,
@@ -1654,22 +1654,17 @@ export namespace LSPServer {
if (!bin) {
if (Flag.KILO_DISABLE_LSP_DOWNLOAD) return
log.info("downloading terraform-ls from GitHub releases")
log.info("downloading terraform-ls from HashiCorp releases")
const releaseResponse = await fetch("https://api.github.com/repos/hashicorp/terraform-ls/releases/latest")
const releaseResponse = await fetch("https://api.releases.hashicorp.com/v1/releases/terraform-ls/latest")
if (!releaseResponse.ok) {
log.error("Failed to fetch terraform-ls release info")
return
}
const release = (await releaseResponse.json()) as {
tag_name?: string
assets?: { name?: string; browser_download_url?: string }[]
}
const version = release.tag_name?.replace("v", "")
if (!version) {
log.error("terraform-ls release did not include a version tag")
return
version?: string
builds?: { arch?: string; os?: string; url?: string }[]
}
const platform = process.platform
@@ -1678,23 +1673,21 @@ export namespace LSPServer {
const tfArch = arch === "arm64" ? "arm64" : "amd64"
const tfPlatform = platform === "win32" ? "windows" : platform
const assetName = `terraform-ls_${version}_${tfPlatform}_${tfArch}.zip`
const assets = release.assets ?? []
const asset = assets.find((a) => a.name === assetName)
if (!asset?.browser_download_url) {
log.error(`Could not find asset ${assetName} in terraform-ls release`)
const builds = release.builds ?? []
const build = builds.find((b) => b.arch === tfArch && b.os === tfPlatform)
if (!build?.url) {
log.error(`Could not find build for ${tfPlatform}/${tfArch} terraform-ls release version ${release.version}`)
return
}
const downloadResponse = await fetch(asset.browser_download_url)
const downloadResponse = await fetch(build.url)
if (!downloadResponse.ok) {
log.error("Failed to download terraform-ls")
return
}
const tempPath = path.join(Global.Path.bin, assetName)
await Bun.file(tempPath).write(downloadResponse)
const tempPath = path.join(Global.Path.bin, "terraform-ls.zip")
if (downloadResponse.body) await Filesystem.writeStream(tempPath, downloadResponse.body)
const ok = await Archive.extractZip(tempPath, Global.Path.bin)
.then(() => true)
@@ -1707,7 +1700,7 @@ export namespace LSPServer {
bin = path.join(Global.Path.bin, "terraform-ls" + (platform === "win32" ? ".exe" : ""))
if (!(await Bun.file(bin).exists())) {
if (!(await Filesystem.exists(bin))) {
log.error("Failed to extract terraform-ls binary")
return
}
@@ -1784,7 +1777,7 @@ export namespace LSPServer {
}
const tempPath = path.join(Global.Path.bin, assetName)
await Bun.file(tempPath).write(downloadResponse)
if (downloadResponse.body) await Filesystem.writeStream(tempPath, downloadResponse.body)
if (ext === "zip") {
const ok = await Archive.extractZip(tempPath, Global.Path.bin)
@@ -1803,7 +1796,7 @@ export namespace LSPServer {
bin = path.join(Global.Path.bin, "texlab" + (platform === "win32" ? ".exe" : ""))
if (!(await Bun.file(bin).exists())) {
if (!(await Filesystem.exists(bin))) {
log.error("Failed to extract texlab binary")
return
}
@@ -1832,7 +1825,7 @@ export namespace LSPServer {
const args: string[] = []
if (!binary) {
const js = path.join(Global.Path.bin, "node_modules", "dockerfile-language-server-nodejs", "lib", "server.js")
if (!(await Bun.file(js).exists())) {
if (!(await Filesystem.exists(js))) {
if (Flag.KILO_DISABLE_LSP_DOWNLOAD) return
await Bun.spawn([BunProc.which(), "install", "dockerfile-language-server-nodejs"], {
cwd: Global.Path.bin,
@@ -1990,7 +1983,7 @@ export namespace LSPServer {
}
const tempPath = path.join(Global.Path.bin, assetName)
await Bun.file(tempPath).write(downloadResponse)
if (downloadResponse.body) await Filesystem.writeStream(tempPath, downloadResponse.body)
if (ext === "zip") {
const ok = await Archive.extractZip(tempPath, Global.Path.bin)
@@ -2008,7 +2001,7 @@ export namespace LSPServer {
bin = path.join(Global.Path.bin, "tinymist" + (platform === "win32" ? ".exe" : ""))
if (!(await Bun.file(bin).exists())) {
if (!(await Filesystem.exists(bin))) {
log.error("Failed to extract tinymist binary")
return
}
@@ -2043,4 +2036,22 @@ export namespace LSPServer {
}
},
}
export const JuliaLS: Info = {
id: "julials",
extensions: [".jl"],
root: NearestRoot(["Project.toml", "Manifest.toml", "*.jl"]),
async spawn(root) {
const julia = Bun.which("julia")
if (!julia) {
log.info("julia not found, please install julia first (https://julialang.org/downloads/)")
return
}
return {
process: spawn(julia, ["--startup-file=no", "--history-file=no", "-e", "using LanguageServer; runserver()"], {
cwd: root,
}),
}
},
}
}
+4 -6
View File
@@ -1,6 +1,7 @@
import path from "path"
import z from "zod"
import { Global } from "../global"
import { Filesystem } from "../util/filesystem"
export namespace McpAuth {
export const Tokens = z.object({
@@ -53,25 +54,22 @@ export namespace McpAuth {
}
export async function all(): Promise<Record<string, Entry>> {
const file = Bun.file(filepath)
return file.json().catch(() => ({}))
return Filesystem.readJson<Record<string, Entry>>(filepath).catch(() => ({}))
}
export async function set(mcpName: string, entry: Entry, serverUrl?: string): Promise<void> {
const file = Bun.file(filepath)
const data = await all()
// Always update serverUrl if provided
if (serverUrl) {
entry.serverUrl = serverUrl
}
await Bun.write(file, JSON.stringify({ ...data, [mcpName]: entry }, null, 2), { mode: 0o600 })
await Filesystem.writeJson(filepath, { ...data, [mcpName]: entry }, 0o600)
}
export async function remove(mcpName: string): Promise<void> {
const file = Bun.file(filepath)
const data = await all()
delete data[mcpName]
await Bun.write(file, JSON.stringify(data, null, 2), { mode: 0o600 })
await Filesystem.writeJson(filepath, data, 0o600)
}
export async function updateTokens(mcpName: string, tokens: Tokens, serverUrl?: string): Promise<void> {
@@ -149,6 +149,28 @@ export class McpOAuthProvider implements OAuthClientProvider {
}
return entry.oauthState
}
async invalidateCredentials(type: "all" | "client" | "tokens"): Promise<void> {
log.info("invalidating credentials", { mcpName: this.mcpName, type })
const entry = await McpAuth.get(this.mcpName)
if (!entry) {
return
}
switch (type) {
case "all":
await McpAuth.remove(this.mcpName)
break
case "client":
delete entry.clientInfo
await McpAuth.set(this.mcpName, entry)
break
case "tokens":
delete entry.tokens
await McpAuth.set(this.mcpName, entry)
break
}
}
}
export { OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH }
+13 -23
View File
@@ -13,6 +13,7 @@ import { iife } from "@/util/iife"
import { GlobalBus } from "@/bus/global"
import { existsSync } from "fs"
import { git } from "../util/git"
import { Glob } from "../util/glob"
export namespace Project {
const log = Log.create({ service: "project" })
@@ -86,8 +87,7 @@ export namespace Project {
const gitBinary = Bun.which("git")
// cached id calculation
let id = await Bun.file(path.join(dotgit, "opencode"))
.text()
let id = await Filesystem.readText(path.join(dotgit, "opencode"))
.then((x) => x.trim())
.catch(() => undefined)
@@ -125,9 +125,7 @@ export namespace Project {
id = roots[0]
if (id) {
void Bun.file(path.join(dotgit, "opencode"))
.write(id)
.catch(() => undefined)
void Filesystem.write(path.join(dotgit, "opencode"), id).catch(() => undefined)
}
}
@@ -265,22 +263,16 @@ export namespace Project {
if (input.vcs !== "git") return
if (input.icon?.override) return
if (input.icon?.url) return
const glob = new Bun.Glob("**/{favicon}.{ico,png,svg,jpg,jpeg,webp}")
const matches = await Array.fromAsync(
glob.scan({
cwd: input.worktree,
absolute: true,
onlyFiles: true,
followSymlinks: false,
dot: false,
}),
)
const matches = await Glob.scan("**/favicon.{ico,png,svg,jpg,jpeg,webp}", {
cwd: input.worktree,
absolute: true,
include: "file",
})
const shortest = matches.sort((a, b) => a.length - b.length)[0]
if (!shortest) return
const file = Bun.file(shortest)
const buffer = await file.arrayBuffer()
const base64 = Buffer.from(buffer).toString("base64")
const mime = file.type || "image/png"
const buffer = await Filesystem.readBytes(shortest)
const base64 = buffer.toString("base64")
const mime = Filesystem.mimeType(shortest) || "image/png"
const url = `data:${mime};base64,${base64}`
await update({
projectID: input.id,
@@ -381,10 +373,8 @@ export namespace Project {
const data = fromRow(row)
const valid: string[] = []
for (const dir of data.sandboxes) {
const stat = await Bun.file(dir)
.stat()
.catch(() => undefined)
if (stat?.isDirectory()) valid.push(dir)
const s = Filesystem.stat(dir)
if (s?.isDirectory()) valid.push(dir)
}
return valid
}
+4 -5
View File
@@ -9,6 +9,7 @@ import { Config } from "../config/config" // kilocode_change
import { ModelCache } from "./model-cache" // kilocode_change
import { Auth } from "../auth" // kilocode_change
import { KILO_OPENROUTER_BASE } from "@kilocode/kilo-gateway" // kilocode_change
import { Filesystem } from "../util/filesystem"
// Try to import bundled snapshot (generated at build time)
// Falls back to undefined in dev mode when snapshot doesn't exist
@@ -105,8 +106,7 @@ export namespace ModelsDev {
}
export const Data = lazy(async () => {
const file = Bun.file(Flag.KILO_MODELS_PATH ?? filepath)
const result = await file.json().catch(() => {})
const result = await Filesystem.readJson(Flag.KILO_MODELS_PATH ?? filepath).catch(() => {})
if (result) return result
// @ts-ignore
const snapshot = await import("./models-snapshot")
@@ -165,7 +165,6 @@ export namespace ModelsDev {
}
export async function refresh() {
const file = Bun.file(filepath)
const result = await fetch(`${url()}/api.json`, {
headers: {
"User-Agent": Installation.USER_AGENT,
@@ -177,13 +176,13 @@ export namespace ModelsDev {
})
})
if (result && result.ok) {
await Bun.write(file, await result.text())
await Filesystem.write(filepath, await result.text())
ModelsDev.Data.reset()
}
}
}
if (!Flag.KILO_DISABLE_MODELS_FETCH) {
if (!Flag.KILO_DISABLE_MODELS_FETCH && !process.argv.includes("--get-yargs-completions")) {
ModelsDev.refresh()
setInterval(
async () => {
+7 -7
View File
@@ -16,6 +16,7 @@ import { Flag } from "../flag/flag"
import { iife } from "@/util/iife"
import { Global } from "../global"
import path from "path"
import { Filesystem } from "../util/filesystem"
// Direct imports for bundled providers
import { createAmazonBedrock, type AmazonBedrockProviderSettings } from "@ai-sdk/amazon-bedrock"
@@ -40,6 +41,8 @@ import { createTogetherAI } from "@ai-sdk/togetherai"
import { createPerplexity } from "@ai-sdk/perplexity"
import { createVercel } from "@ai-sdk/vercel"
import { createGitLab, VERSION as GITLAB_PROVIDER_VERSION } from "@gitlab/gitlab-ai-provider"
import { fromNodeProviderChain } from "@aws-sdk/credential-providers"
import { GoogleAuth } from "google-auth-library"
import { ProviderTransform } from "./transform"
import { Installation } from "../installation"
@@ -236,8 +239,6 @@ export namespace Provider {
// Only use credential chain if no bearer token exists
// Bearer token takes precedence over credential chain (profiles, access keys, IAM roles, web identity tokens)
if (!awsBearerToken) {
const { fromNodeProviderChain } = await import(await BunProc.install("@aws-sdk/credential-providers"))
// Build credential provider options (only pass profile if specified)
const credentialProviderOptions = profile ? { profile } : {}
@@ -374,11 +375,9 @@ export namespace Provider {
project,
location,
fetch: async (input: RequestInfo | URL, init?: RequestInit) => {
const { GoogleAuth } = await import(await BunProc.install("google-auth-library"))
const auth = new GoogleAuth()
const client = await auth.getApplicationDefault()
const credentials = await client.credential
const token = await credentials.getAccessToken()
const token = await client.credential.getAccessToken()
const headers = new Headers(init?.headers)
headers.set("Authorization", `Bearer ${token.token}`)
@@ -1309,8 +1308,9 @@ export namespace Provider {
if (cfg.model) return parseModel(cfg.model)
const providers = await list()
const recent = (await Bun.file(path.join(Global.Path.state, "model.json"))
.json()
const recent = (await Filesystem.readJson<{ recent?: { providerID: string; modelID: string }[] }>(
path.join(Global.Path.state, "model.json"),
)
.then((x) => (Array.isArray(x.recent) ? x.recent : []))
.catch(() => [])) as { providerID: string; modelID: string }[]
for (const entry of recent) {
+11 -5
View File
@@ -634,12 +634,19 @@ export namespace ProviderTransform {
},
}
}
let levels = ["low", "high"]
if (id.includes("3.1")) {
levels = ["low", "medium", "high"]
}
return Object.fromEntries(
["low", "high"].map((effort) => [
levels.map((effort) => [
effort,
{
includeThoughts: true,
thinkingLevel: effort,
thinkingConfig: {
includeThoughts: true,
thinkingLevel: effort,
},
},
]),
)
@@ -659,8 +666,7 @@ export namespace ProviderTransform {
groqEffort.map((effort) => [
effort,
{
includeThoughts: true,
thinkingLevel: effort,
reasoningEffort: effort,
},
]),
)
+58 -13
View File
@@ -18,11 +18,18 @@ export namespace Pty {
type Socket = {
readyState: number
send: (data: string | Uint8Array<ArrayBuffer> | ArrayBuffer) => void
data?: unknown
send: (data: string | Uint8Array | ArrayBuffer) => void
close: (code?: number, reason?: string) => void
}
type Subscriber = {
id: number
token: unknown
}
const sockets = new WeakMap<object, number>()
const owners = new WeakMap<object, string>()
let socketCounter = 0
const tagSocket = (ws: Socket) => {
@@ -32,7 +39,20 @@ export namespace Pty {
return next
}
// WebSocket control frame: 0x00 + UTF-8 JSON (currently { cursor }).
const token = (ws: Socket) => {
const data = ws.data
if (!data || typeof data !== "object") return
const events = (data as { events?: unknown }).events
if (events && typeof events === "object") return events
const url = (data as { url?: unknown }).url
if (url && typeof url === "object") return url
return data
}
// WebSocket control frame: 0x00 + UTF-8 JSON.
const meta = (cursor: number) => {
const json = JSON.stringify({ cursor })
const bytes = encoder.encode(json)
@@ -96,7 +116,7 @@ export namespace Pty {
buffer: string
bufferCursor: number
cursor: number
subscribers: Map<Socket, number>
subscribers: Map<Socket, Subscriber>
}
const state = Instance.state(
@@ -176,26 +196,33 @@ export namespace Pty {
subscribers: new Map(),
}
state().set(id, session)
ptyProcess.onData((data) => {
session.cursor += data.length
ptyProcess.onData((chunk) => {
session.cursor += chunk.length
for (const [ws, id] of session.subscribers) {
for (const [ws, sub] of session.subscribers) {
if (ws.readyState !== 1) {
session.subscribers.delete(ws)
continue
}
if (typeof ws === "object" && sockets.get(ws) !== id) {
if (typeof ws === "object" && sockets.get(ws) !== sub.id) {
session.subscribers.delete(ws)
continue
}
if (sub.token !== undefined && token(ws) !== sub.token) {
session.subscribers.delete(ws)
continue
}
try {
ws.send(data)
ws.send(chunk)
} catch {
session.subscribers.delete(ws)
}
}
session.buffer += data
session.buffer += chunk
if (session.buffer.length <= BUFFER_LIMIT) return
const excess = session.buffer.length - BUFFER_LIMIT
session.buffer = session.buffer.slice(excess)
@@ -273,6 +300,25 @@ export namespace Pty {
}
log.info("client connected to session", { id })
const socketId = tagSocket(ws)
if (socketId === undefined) {
ws.close()
return
}
const previous = owners.get(ws)
if (previous && previous !== id) {
state().get(previous)?.subscribers.delete(ws)
}
owners.set(ws, id)
session.subscribers.set(ws, { id: socketId, token: token(ws) })
const cleanup = () => {
session.subscribers.delete(ws)
if (owners.get(ws) === id) owners.delete(ws)
}
const start = session.bufferCursor
const end = session.cursor
@@ -293,6 +339,7 @@ export namespace Pty {
ws.send(data.slice(i, i + BUFFER_CHUNK))
}
} catch {
cleanup()
ws.close()
return
}
@@ -301,19 +348,17 @@ export namespace Pty {
try {
ws.send(meta(end))
} catch {
cleanup()
ws.close()
return
}
const socketId = tagSocket(ws)
if (typeof socketId === "number") session.subscribers.set(ws, socketId)
return {
onMessage: (message: string | ArrayBuffer) => {
session.process.write(String(message))
},
onClose: () => {
log.info("client disconnected from session", { id })
session.subscribers.delete(ws)
cleanup()
},
}
}
@@ -66,6 +66,8 @@ export const GlobalRoutes = lazy(() =>
}),
async (c) => {
log.info("global event connected")
c.header("X-Accel-Buffering", "no")
c.header("X-Content-Type-Options", "nosniff")
return streamSSE(c, async (stream) => {
stream.writeSSE({
data: JSON.stringify({
@@ -82,7 +84,7 @@ export const GlobalRoutes = lazy(() =>
}
GlobalBus.on("event", handler)
// Send heartbeat every 30s to prevent WKWebView timeout (60s default)
// Send heartbeat every 10s to prevent stalled proxy streams.
const heartbeat = setInterval(() => {
stream.writeSSE({
data: JSON.stringify({
@@ -92,7 +94,7 @@ export const GlobalRoutes = lazy(() =>
},
}),
})
}, 30000)
}, 10_000)
await new Promise<void>((resolve) => {
stream.onAbort(() => {
+8 -3
View File
@@ -163,7 +163,7 @@ export const PtyRoutes = lazy(() =>
type Socket = {
readyState: number
send: (data: string | Uint8Array<ArrayBuffer> | ArrayBuffer) => void
send: (data: string | Uint8Array | ArrayBuffer) => void
close: (code?: number, reason?: string) => void
}
@@ -177,11 +177,16 @@ export const PtyRoutes = lazy(() =>
return {
onOpen(_event, ws) {
const socket = isSocket(ws.raw) ? ws.raw : ws
const socket = ws.raw
if (!isSocket(socket)) {
ws.close()
return
}
handler = Pty.connect(id, socket, cursor)
},
onMessage(event) {
handler?.onMessage(String(event.data))
if (typeof event.data !== "string") return
handler?.onMessage(event.data)
},
onClose() {
handler?.onClose()
+4 -2
View File
@@ -520,6 +520,8 @@ export namespace Server {
}),
async (c) => {
log.info("event connected")
c.header("X-Accel-Buffering", "no")
c.header("X-Content-Type-Options", "nosniff")
return streamSSE(c, async (stream) => {
stream.writeSSE({
data: JSON.stringify({
@@ -536,7 +538,7 @@ export namespace Server {
}
})
// Send heartbeat every 30s to prevent WKWebView timeout (60s default)
// Send heartbeat every 10s to prevent stalled proxy streams.
const heartbeat = setInterval(() => {
stream.writeSSE({
data: JSON.stringify({
@@ -544,7 +546,7 @@ export namespace Server {
properties: {},
}),
})
}, 30000)
}, 10_000)
await new Promise<void>((resolve) => {
stream.onAbort(() => {
+1 -1
View File
@@ -614,7 +614,7 @@ export namespace Session {
})
export const updateMessage = fn(MessageV2.Info, async (msg) => {
const time_created = msg.role === "user" ? msg.time.created : msg.time.created
const time_created = msg.time.created
const { id, sessionID, ...data } = msg
Database.use((db) => {
db.insert(MessageTable)
+10 -15
View File
@@ -6,6 +6,7 @@ import { Config } from "../config/config"
import { Instance } from "../project/instance"
import { Flag } from "@/flag/flag"
import { Log } from "../util/log"
import { Glob } from "../util/glob"
import type { MessageV2 } from "./message-v2"
const log = Log.create({ service: "instruction" })
@@ -83,7 +84,7 @@ export namespace InstructionPrompt {
}
for (const file of globalFiles()) {
if (await Bun.file(file).exists()) {
if (await Filesystem.exists(file)) {
paths.add(path.resolve(file))
break
}
@@ -96,13 +97,11 @@ export namespace InstructionPrompt {
instruction = path.join(os.homedir(), instruction.slice(2))
}
const matches = path.isAbsolute(instruction)
? await Array.fromAsync(
new Bun.Glob(path.basename(instruction)).scan({
cwd: path.dirname(instruction),
absolute: true,
onlyFiles: true,
}),
).catch(() => [])
? await Glob.scan(path.basename(instruction), {
cwd: path.dirname(instruction),
absolute: true,
include: "file",
}).catch(() => [])
: await resolveRelative(instruction)
matches.forEach((p) => {
paths.add(path.resolve(p))
@@ -118,9 +117,7 @@ export namespace InstructionPrompt {
const paths = await systemPaths()
const files = Array.from(paths).map(async (p) => {
const content = await Bun.file(p)
.text()
.catch(() => "")
const content = await Filesystem.readText(p).catch(() => "")
return content ? "Instructions from: " + p + "\n" + content : ""
})
@@ -162,7 +159,7 @@ export namespace InstructionPrompt {
export async function find(dir: string) {
for (const file of FILES) {
const filepath = path.resolve(path.join(dir, file))
if (await Bun.file(filepath).exists()) return filepath
if (await Filesystem.exists(filepath)) return filepath
}
}
@@ -180,9 +177,7 @@ export namespace InstructionPrompt {
if (found && found !== target && !system.has(found) && !already.has(found) && !isClaimed(messageID, found)) {
claim(messageID, found)
const content = await Bun.file(found)
.text()
.catch(() => undefined)
const content = await Filesystem.readText(found).catch(() => undefined)
if (content) {
results.push({ filepath: found, content: "Instructions from: " + found + "\n" + content })
}
+42 -44
View File
@@ -2,6 +2,7 @@ import path from "path"
import os from "os"
import fs from "fs/promises"
import z from "zod"
import { Filesystem } from "../util/filesystem"
import { Identifier } from "../id/id"
import { MessageV2 } from "./message-v2"
import { Log } from "../util/log"
@@ -477,6 +478,12 @@ export namespace SessionPrompt {
log.error("subtask execution failed", { error, agent: task.agent, description: task.description })
return undefined
})
const attachments = result?.attachments?.map((attachment) => ({
...attachment,
id: Identifier.ascending("part"),
sessionID,
messageID: assistantMessage.id,
}))
await Plugin.trigger(
"tool.execute.after",
{
@@ -499,7 +506,7 @@ export namespace SessionPrompt {
title: result.title,
metadata: result.metadata,
output: result.output,
attachments: result.attachments,
attachments,
time: {
...part.state.time,
end: Date.now(),
@@ -838,6 +845,15 @@ export namespace SessionPrompt {
},
)
const result = await item.execute(args, ctx)
const output = {
...result,
attachments: result.attachments?.map((attachment) => ({
...attachment,
id: Identifier.ascending("part"),
sessionID: ctx.sessionID,
messageID: input.processor.message.id,
})),
}
await Plugin.trigger(
"tool.execute.after",
{
@@ -846,9 +862,9 @@ export namespace SessionPrompt {
callID: ctx.callID,
args,
},
result,
output,
)
return result
return output
},
})
}
@@ -896,16 +912,13 @@ export namespace SessionPrompt {
)
const textParts: string[] = []
const attachments: MessageV2.FilePart[] = []
const attachments: Omit<MessageV2.FilePart, "id" | "sessionID" | "messageID">[] = []
for (const contentItem of result.content) {
if (contentItem.type === "text") {
textParts.push(contentItem.text)
} else if (contentItem.type === "image") {
attachments.push({
id: Identifier.ascending("part"),
sessionID: input.session.id,
messageID: input.processor.message.id,
type: "file",
mime: contentItem.mimeType,
url: `data:${contentItem.mimeType};base64,${contentItem.data}`,
@@ -917,9 +930,6 @@ export namespace SessionPrompt {
}
if (resource.blob) {
attachments.push({
id: Identifier.ascending("part"),
sessionID: input.session.id,
messageID: input.processor.message.id,
type: "file",
mime: resource.mimeType ?? "application/octet-stream",
url: `data:${resource.mimeType ?? "application/octet-stream"};base64,${resource.blob}`,
@@ -1007,17 +1017,22 @@ export namespace SessionPrompt {
}
using _ = defer(() => InstructionPrompt.clear(info.id))
type Draft<T> = T extends MessageV2.Part ? Omit<T, "id"> & { id?: string } : never
const assign = (part: Draft<MessageV2.Part>): MessageV2.Part => ({
...part,
id: part.id ?? Identifier.ascending("part"),
})
const parts = await Promise.all(
input.parts.map(async (part): Promise<MessageV2.Part[]> => {
input.parts.map(async (part): Promise<Draft<MessageV2.Part>[]> => {
if (part.type === "file") {
// before checking the protocol we check if this is an mcp resource because it needs special handling
if (part.source?.type === "resource") {
const { clientName, uri } = part.source
log.info("mcp resource", { clientName, uri, mime: part.mime })
const pieces: MessageV2.Part[] = [
const pieces: Draft<MessageV2.Part>[] = [
{
id: Identifier.ascending("part"),
messageID: info.id,
sessionID: input.sessionID,
type: "text",
@@ -1040,7 +1055,6 @@ export namespace SessionPrompt {
for (const content of contents) {
if ("text" in content && content.text) {
pieces.push({
id: Identifier.ascending("part"),
messageID: info.id,
sessionID: input.sessionID,
type: "text",
@@ -1051,7 +1065,6 @@ export namespace SessionPrompt {
// Handle binary content if needed
const mimeType = "mimeType" in content ? content.mimeType : part.mime
pieces.push({
id: Identifier.ascending("part"),
messageID: info.id,
sessionID: input.sessionID,
type: "text",
@@ -1063,7 +1076,6 @@ export namespace SessionPrompt {
pieces.push({
...part,
id: part.id ?? Identifier.ascending("part"),
messageID: info.id,
sessionID: input.sessionID,
})
@@ -1071,7 +1083,6 @@ export namespace SessionPrompt {
log.error("failed to read MCP resource", { error, clientName, uri })
const message = error instanceof Error ? error.message : String(error)
pieces.push({
id: Identifier.ascending("part"),
messageID: info.id,
sessionID: input.sessionID,
type: "text",
@@ -1088,7 +1099,6 @@ export namespace SessionPrompt {
if (part.mime === "text/plain") {
return [
{
id: Identifier.ascending("part"),
messageID: info.id,
sessionID: input.sessionID,
type: "text",
@@ -1096,7 +1106,6 @@ export namespace SessionPrompt {
text: `Called the Read tool with the following input: ${JSON.stringify({ filePath: part.filename })}`,
},
{
id: Identifier.ascending("part"),
messageID: info.id,
sessionID: input.sessionID,
type: "text",
@@ -1105,7 +1114,6 @@ export namespace SessionPrompt {
},
{
...part,
id: part.id ?? Identifier.ascending("part"),
messageID: info.id,
sessionID: input.sessionID,
},
@@ -1117,11 +1125,9 @@ export namespace SessionPrompt {
// have to normalize, symbol search returns absolute paths
// Decode the pathname since URL constructor doesn't automatically decode it
const filepath = fileURLToPath(part.url)
const stat = await Bun.file(filepath)
.stat()
.catch(() => undefined)
const s = Filesystem.stat(filepath)
if (stat?.isDirectory()) {
if (s?.isDirectory()) {
part.mime = "application/x-directory"
}
@@ -1162,9 +1168,8 @@ export namespace SessionPrompt {
}
const args = { filePath: filepath, offset, limit }
const pieces: MessageV2.Part[] = [
const pieces: Draft<MessageV2.Part>[] = [
{
id: Identifier.ascending("part"),
messageID: info.id,
sessionID: input.sessionID,
type: "text",
@@ -1188,7 +1193,6 @@ export namespace SessionPrompt {
}
const result = await t.execute(args, readCtx)
pieces.push({
id: Identifier.ascending("part"),
messageID: info.id,
sessionID: input.sessionID,
type: "text",
@@ -1208,7 +1212,6 @@ export namespace SessionPrompt {
} else {
pieces.push({
...part,
id: part.id ?? Identifier.ascending("part"),
messageID: info.id,
sessionID: input.sessionID,
})
@@ -1224,7 +1227,6 @@ export namespace SessionPrompt {
}).toObject(),
})
pieces.push({
id: Identifier.ascending("part"),
messageID: info.id,
sessionID: input.sessionID,
type: "text",
@@ -1251,7 +1253,6 @@ export namespace SessionPrompt {
const result = await ReadTool.init().then((t) => t.execute(args, listCtx))
return [
{
id: Identifier.ascending("part"),
messageID: info.id,
sessionID: input.sessionID,
type: "text",
@@ -1259,7 +1260,6 @@ export namespace SessionPrompt {
text: `Called the Read tool with the following input: ${JSON.stringify(args)}`,
},
{
id: Identifier.ascending("part"),
messageID: info.id,
sessionID: input.sessionID,
type: "text",
@@ -1268,30 +1268,27 @@ export namespace SessionPrompt {
},
{
...part,
id: part.id ?? Identifier.ascending("part"),
messageID: info.id,
sessionID: input.sessionID,
},
]
}
const file = Bun.file(filepath)
FileTime.read(input.sessionID, filepath)
return [
{
id: Identifier.ascending("part"),
messageID: info.id,
sessionID: input.sessionID,
type: "text",
text: `Called the Read tool with the following input: {\"filePath\":\"${filepath}\"}`,
text: `Called the Read tool with the following input: {"filePath":"${filepath}"}`,
synthetic: true,
},
{
id: part.id ?? Identifier.ascending("part"),
id: part.id,
messageID: info.id,
sessionID: input.sessionID,
type: "file",
url: `data:${part.mime};base64,` + Buffer.from(await file.bytes()).toString("base64"),
url: `data:${part.mime};base64,` + (await Filesystem.readBytes(filepath)).toString("base64"),
mime: part.mime,
filename: part.filename!,
source: part.source,
@@ -1306,13 +1303,11 @@ export namespace SessionPrompt {
const hint = perm.action === "deny" ? " . Invoked by user; guaranteed to exist." : ""
return [
{
id: Identifier.ascending("part"),
...part,
messageID: info.id,
sessionID: input.sessionID,
},
{
id: Identifier.ascending("part"),
messageID: info.id,
sessionID: input.sessionID,
type: "text",
@@ -1329,14 +1324,13 @@ export namespace SessionPrompt {
return [
{
id: Identifier.ascending("part"),
...part,
messageID: info.id,
sessionID: input.sessionID,
},
]
}),
).then((x) => x.flat())
).then((x) => x.flat().map(assign))
await Plugin.trigger(
"chat.message",
@@ -1402,7 +1396,7 @@ export namespace SessionPrompt {
// Switching from plan mode to build mode
if (input.agent.name !== "plan" && assistantMessage?.info.agent === "plan") {
const plan = Session.plan(input.session)
const exists = await Bun.file(plan).exists()
const exists = await Filesystem.exists(plan)
if (exists) {
const part = await Session.updatePart({
id: Identifier.ascending("part"),
@@ -1421,7 +1415,7 @@ export namespace SessionPrompt {
// Entering plan mode
if (input.agent.name === "plan" && assistantMessage?.info.agent !== "plan") {
const plan = Session.plan(input.session)
const exists = await Bun.file(plan).exists()
const exists = await Filesystem.exists(plan)
if (!exists) await fs.mkdir(path.dirname(plan), { recursive: true })
const part = await Session.updatePart({
id: Identifier.ascending("part"),
@@ -1666,7 +1660,11 @@ NOTE: At any point in time through this workflow you should feel free to ask the
const args = matchingInvocation?.args
const cwd = Instance.directory
const shellEnv = await Plugin.trigger("shell.env", { cwd }, { env: {} })
const shellEnv = await Plugin.trigger(
"shell.env",
{ cwd, sessionID: input.sessionID, callID: part.callID },
{ env: {} },
)
const proc = spawn(shell, args, {
cwd,
detached: process.platform !== "win32",
+2 -1
View File
@@ -1,5 +1,6 @@
import { Flag } from "@/flag/flag"
import { lazy } from "@/util/lazy"
import { Filesystem } from "@/util/filesystem"
import path from "path"
import { spawn, type ChildProcess } from "child_process"
@@ -43,7 +44,7 @@ export namespace Shell {
// git.exe is typically at: C:\Program Files\Git\cmd\git.exe
// bash.exe is at: C:\Program Files\Git\bin\bash.exe
const bash = path.join(git, "..", "..", "bin", "bash.exe")
if (Bun.file(bash).size) return bash
if (Filesystem.stat(bash)?.size) return bash
}
return process.env.COMSPEC || "cmd.exe"
}
+4 -3
View File
@@ -2,6 +2,7 @@ import path from "path"
import { mkdir } from "fs/promises"
import { Log } from "../util/log"
import { Global } from "../global"
import { Filesystem } from "../util/filesystem"
export namespace Discovery {
const log = Log.create({ service: "skill-discovery" })
@@ -19,14 +20,14 @@ export namespace Discovery {
}
async function get(url: string, dest: string): Promise<boolean> {
if (await Bun.file(dest).exists()) return true
if (await Filesystem.exists(dest)) return true
return fetch(url)
.then(async (response) => {
if (!response.ok) {
log.error("failed to download", { url, status: response.status })
return false
}
await Bun.write(dest, await response.text())
if (response.body) await Filesystem.writeStream(dest, response.body)
return true
})
.catch((err) => {
@@ -88,7 +89,7 @@ export namespace Discovery {
)
const md = path.join(root, "SKILL.md")
if (await Bun.file(md).exists()) result.push(root)
if (await Filesystem.exists(md)) result.push(root)
}),
)
+29 -29
View File
@@ -12,6 +12,7 @@ import { Flag } from "@/flag/flag"
import { Bus } from "@/bus"
import { Session } from "@/session"
import { Discovery } from "./discovery"
import { Glob } from "../util/glob"
import { KilocodePaths } from "../kilocode/paths" // kilocode_change
@@ -46,10 +47,9 @@ export namespace Skill {
// External skill directories to search for (project-level and global)
// These follow the directory layout used by Claude Code and other agents.
const EXTERNAL_DIRS = [".claude", ".agents"]
const EXTERNAL_SKILL_GLOB = new Bun.Glob("skills/**/SKILL.md")
const KILO_SKILL_GLOB = new Bun.Glob("{skill,skills}/**/SKILL.md")
const SKILL_GLOB = new Bun.Glob("**/SKILL.md")
const EXTERNAL_SKILL_PATTERN = "skills/**/SKILL.md"
const KILO_SKILL_PATTERN = "{skill,skills}/**/SKILL.md"
const SKILL_PATTERN = "**/SKILL.md"
export const state = Instance.state(async () => {
const skills: Record<string, Info> = {}
@@ -90,15 +90,13 @@ export namespace Skill {
}
const scanExternal = async (root: string, scope: "global" | "project") => {
return Array.fromAsync(
EXTERNAL_SKILL_GLOB.scan({
cwd: root,
absolute: true,
onlyFiles: true,
followSymlinks: true,
dot: true,
}),
)
return Glob.scan(EXTERNAL_SKILL_PATTERN, {
cwd: root,
absolute: true,
include: "file",
dot: true,
symlink: true,
})
.then((matches) => Promise.all(matches.map(addSkill)))
.catch((error) => {
log.error(`failed to scan ${scope} skills`, { dir: root, error })
@@ -131,12 +129,11 @@ export namespace Skill {
})
for (const dir of kilocodeSkillDirs) {
const matches = await Array.fromAsync(
KILO_SKILL_GLOB.scan({
await Glob.scan(KILO_SKILL_PATTERN, {
cwd: dir,
absolute: true,
onlyFiles: true,
followSymlinks: true,
dot: true,
include: "file",
symlink: true,
}),
).catch((error) => {
log.error("failed .kilocode directory scan for skills", { dir, error })
@@ -151,12 +148,13 @@ export namespace Skill {
// Scan .opencode/skill/ directories
for (const dir of await Config.directories()) {
for await (const match of KILO_SKILL_GLOB.scan({
const matches = await Glob.scan(KILO_SKILL_PATTERN, {
cwd: dir,
absolute: true,
onlyFiles: true,
followSymlinks: true,
})) {
include: "file",
symlink: true,
})
for (const match of matches) {
await addSkill(match)
}
}
@@ -170,12 +168,13 @@ export namespace Skill {
log.warn("skill path not found", { path: resolved })
continue
}
for await (const match of SKILL_GLOB.scan({
const matches = await Glob.scan(SKILL_PATTERN, {
cwd: resolved,
absolute: true,
onlyFiles: true,
followSymlinks: true,
})) {
include: "file",
symlink: true,
})
for (const match of matches) {
await addSkill(match)
}
}
@@ -185,12 +184,13 @@ export namespace Skill {
const list = await Discovery.pull(url)
for (const dir of list) {
dirs.add(dir)
for await (const match of SKILL_GLOB.scan({
const matches = await Glob.scan(SKILL_PATTERN, {
cwd: dir,
absolute: true,
onlyFiles: true,
followSymlinks: true,
})) {
include: "file",
symlink: true,
})
for (const match of matches) {
await addSkill(match)
}
}
+2 -2
View File
@@ -10,7 +10,7 @@ import { Log } from "../util/log"
import { NamedError } from "@opencode-ai/util/error"
import z from "zod"
import path from "path"
import { readFileSync, readdirSync } from "fs"
import { readFileSync, readdirSync, existsSync } from "fs"
import * as schema from "./schema"
declare const KILO_MIGRATIONS: { sql: string; timestamp: number }[] | undefined
@@ -54,7 +54,7 @@ export namespace Database {
const sql = dirs
.map((name) => {
const file = path.join(dir, name, "migration.sql")
if (!Bun.file(file).size) return
if (!existsSync(file)) return
return {
sql: readFileSync(file, "utf-8"),
timestamp: time(name),
@@ -7,6 +7,8 @@ import { SessionTable, MessageTable, PartTable, TodoTable, PermissionTable } fro
import { SessionShareTable } from "../share/share.sql"
import path from "path"
import { existsSync } from "fs"
import { Filesystem } from "../util/filesystem"
import { Glob } from "../util/glob"
export namespace JsonMigration {
const log = Log.create({ service: "json-migration" })
@@ -70,19 +72,14 @@ export namespace JsonMigration {
const now = Date.now()
async function list(pattern: string) {
const items: string[] = []
const scan = new Bun.Glob(pattern)
for await (const file of scan.scan({ cwd: storageDir, absolute: true })) {
items.push(file)
}
return items
return Glob.scan(pattern, { cwd: storageDir, absolute: true })
}
async function read(files: string[], start: number, end: number) {
const count = end - start
const tasks = new Array(count)
for (let i = 0; i < count; i++) {
tasks[i] = Bun.file(files[start + i]).json()
tasks[i] = Filesystem.readJson(files[start + i])
}
const results = await Promise.allSettled(tasks)
const items = new Array(count)
+52 -59
View File
@@ -8,6 +8,7 @@ import { Lock } from "../util/lock"
import { $ } from "bun"
import { NamedError } from "@opencode-ai/util/error"
import z from "zod"
import { Glob } from "../util/glob"
export namespace Storage {
const log = Log.create({ service: "storage" })
@@ -25,21 +26,24 @@ export namespace Storage {
async (dir) => {
const project = path.resolve(dir, "../project")
if (!(await Filesystem.isDir(project))) return
for await (const projectDir of new Bun.Glob("*").scan({
const projectDirs = await Glob.scan("*", {
cwd: project,
onlyFiles: false,
})) {
include: "all",
})
for (const projectDir of projectDirs) {
const fullPath = path.join(project, projectDir)
if (!(await Filesystem.isDir(fullPath))) continue
log.info(`migrating project ${projectDir}`)
let projectID = projectDir
const fullProjectDir = path.join(project, projectDir)
let worktree = "/"
if (projectID !== "global") {
for await (const msgFile of new Bun.Glob("storage/session/message/*/*.json").scan({
for (const msgFile of await Glob.scan("storage/session/message/*/*.json", {
cwd: path.join(project, projectDir),
absolute: true,
})) {
const json = await Bun.file(msgFile).json()
const json = await Filesystem.readJson<any>(msgFile)
worktree = json.path?.root
if (worktree) break
}
@@ -60,21 +64,18 @@ export namespace Storage {
if (!id) continue
projectID = id
await Bun.write(
path.join(dir, "project", projectID + ".json"),
JSON.stringify({
id,
vcs: "git",
worktree,
time: {
created: Date.now(),
initialized: Date.now(),
},
}),
)
await Filesystem.writeJson(path.join(dir, "project", projectID + ".json"), {
id,
vcs: "git",
worktree,
time: {
created: Date.now(),
initialized: Date.now(),
},
})
log.info(`migrating sessions for project ${projectID}`)
for await (const sessionFile of new Bun.Glob("storage/session/info/*.json").scan({
for (const sessionFile of await Glob.scan("storage/session/info/*.json", {
cwd: fullProjectDir,
absolute: true,
})) {
@@ -83,10 +84,10 @@ export namespace Storage {
sessionFile,
dest,
})
const session = await Bun.file(sessionFile).json()
await Bun.write(dest, JSON.stringify(session))
const session = await Filesystem.readJson<any>(sessionFile)
await Filesystem.writeJson(dest, session)
log.info(`migrating messages for session ${session.id}`)
for await (const msgFile of new Bun.Glob(`storage/session/message/${session.id}/*.json`).scan({
for (const msgFile of await Glob.scan(`storage/session/message/${session.id}/*.json`, {
cwd: fullProjectDir,
absolute: true,
})) {
@@ -95,23 +96,21 @@ export namespace Storage {
msgFile,
dest,
})
const message = await Bun.file(msgFile).json()
await Bun.write(dest, JSON.stringify(message))
const message = await Filesystem.readJson<any>(msgFile)
await Filesystem.writeJson(dest, message)
log.info(`migrating parts for message ${message.id}`)
for await (const partFile of new Bun.Glob(`storage/session/part/${session.id}/${message.id}/*.json`).scan(
{
cwd: fullProjectDir,
absolute: true,
},
)) {
for (const partFile of await Glob.scan(`storage/session/part/${session.id}/${message.id}/*.json`, {
cwd: fullProjectDir,
absolute: true,
})) {
const dest = path.join(dir, "part", message.id, path.basename(partFile))
const part = await Bun.file(partFile).json()
const part = await Filesystem.readJson(partFile)
log.info("copying", {
partFile,
dest,
})
await Bun.write(dest, JSON.stringify(part))
await Filesystem.writeJson(dest, part)
}
}
}
@@ -119,39 +118,36 @@ export namespace Storage {
}
},
async (dir) => {
for await (const item of new Bun.Glob("session/*/*.json").scan({
for (const item of await Glob.scan("session/*/*.json", {
cwd: dir,
absolute: true,
})) {
const session = await Bun.file(item).json()
const session = await Filesystem.readJson<any>(item)
if (!session.projectID) continue
if (!session.summary?.diffs) continue
const { diffs } = session.summary
await Bun.file(path.join(dir, "session_diff", session.id + ".json")).write(JSON.stringify(diffs))
await Bun.file(path.join(dir, "session", session.projectID, session.id + ".json")).write(
JSON.stringify({
...session,
summary: {
additions: diffs.reduce((sum: any, x: any) => sum + x.additions, 0),
deletions: diffs.reduce((sum: any, x: any) => sum + x.deletions, 0),
},
}),
)
await Filesystem.write(path.join(dir, "session_diff", session.id + ".json"), JSON.stringify(diffs))
await Filesystem.writeJson(path.join(dir, "session", session.projectID, session.id + ".json"), {
...session,
summary: {
additions: diffs.reduce((sum: any, x: any) => sum + x.additions, 0),
deletions: diffs.reduce((sum: any, x: any) => sum + x.deletions, 0),
},
})
}
},
]
const state = lazy(async () => {
const dir = path.join(Global.Path.data, "storage")
const migration = await Bun.file(path.join(dir, "migration"))
.json()
const migration = await Filesystem.readJson<string>(path.join(dir, "migration"))
.then((x) => parseInt(x))
.catch(() => 0)
for (let index = migration; index < MIGRATIONS.length; index++) {
log.info("running migration", { index })
const migration = MIGRATIONS[index]
await migration(dir).catch(() => log.error("failed to run migration", { index }))
await Bun.write(path.join(dir, "migration"), (index + 1).toString())
await Filesystem.write(path.join(dir, "migration"), (index + 1).toString())
}
return {
dir,
@@ -171,7 +167,7 @@ export namespace Storage {
const target = path.join(dir, ...key) + ".json"
return withErrorHandling(async () => {
using _ = await Lock.read(target)
const result = await Bun.file(target).json()
const result = await Filesystem.readJson<T>(target)
return result as T
})
}
@@ -181,10 +177,10 @@ export namespace Storage {
const target = path.join(dir, ...key) + ".json"
return withErrorHandling(async () => {
using _ = await Lock.write(target)
const content = await Bun.file(target).json()
fn(content)
await Bun.write(target, JSON.stringify(content, null, 2))
return content as T
const content = await Filesystem.readJson<T>(target)
fn(content as T)
await Filesystem.writeJson(target, content)
return content
})
}
@@ -193,7 +189,7 @@ export namespace Storage {
const target = path.join(dir, ...key) + ".json"
return withErrorHandling(async () => {
using _ = await Lock.write(target)
await Bun.write(target, JSON.stringify(content, null, 2))
await Filesystem.writeJson(target, content)
})
}
@@ -208,16 +204,13 @@ export namespace Storage {
})
}
const glob = new Bun.Glob("**/*")
export async function list(prefix: string[]) {
const dir = await state().then((x) => x.dir)
try {
const result = await Array.fromAsync(
glob.scan({
cwd: path.join(dir, ...prefix),
onlyFiles: true,
}),
).then((results) => results.map((x) => [...prefix, ...x.slice(0, -5).split(path.sep)]))
const result = await Glob.scan("**/*", {
cwd: path.join(dir, ...prefix),
include: "file",
}).then((results) => results.map((x) => [...prefix, ...x.slice(0, -5).split(path.sep)]))
result.sort()
return result
} catch {
+5 -1
View File
@@ -163,7 +163,11 @@ export const BashTool = Tool.define("bash", async () => {
})
}
const shellEnv = await Plugin.trigger("shell.env", { cwd }, { env: {} })
const shellEnv = await Plugin.trigger(
"shell.env",
{ cwd, sessionID: ctx.sessionID, callID: ctx.callID },
{ env: {} },
)
const proc = spawn(params.command, {
shell,
cwd,
+7 -1
View File
@@ -77,6 +77,12 @@ export const BatchTool = Tool.define("batch", async () => {
})
const result = await tool.execute(validatedParams, { ...ctx, callID: partID })
const attachments = result.attachments?.map((attachment) => ({
...attachment,
id: Identifier.ascending("part"),
sessionID: ctx.sessionID,
messageID: ctx.messageID,
}))
await Session.updatePart({
id: partID,
@@ -91,7 +97,7 @@ export const BatchTool = Tool.define("batch", async () => {
output: result.output,
title: result.title,
metadata: result.metadata,
attachments: result.attachments,
attachments,
time: {
start: callStartTime,
end: Date.now(),
+6 -7
View File
@@ -49,7 +49,7 @@ export const EditTool = Tool.define("edit", {
let contentNew = ""
await FileTime.withLock(filePath, async () => {
if (params.oldString === "") {
const existed = await Bun.file(filePath).exists()
const existed = await Filesystem.exists(filePath)
contentNew = params.newString
diff = trimDiff(createTwoFilesPatch(filePath, filePath, contentOld, contentNew))
await ctx.ask({
@@ -61,7 +61,7 @@ export const EditTool = Tool.define("edit", {
diff,
},
})
await Bun.write(filePath, params.newString)
await Filesystem.write(filePath, params.newString)
await Bus.publish(File.Event.Edited, {
file: filePath,
})
@@ -73,12 +73,11 @@ export const EditTool = Tool.define("edit", {
return
}
const file = Bun.file(filePath)
const stats = await file.stat().catch(() => {})
const stats = Filesystem.stat(filePath)
if (!stats) throw new Error(`File ${filePath} not found`)
if (stats.isDirectory()) throw new Error(`Path is a directory, not a file: ${filePath}`)
await FileTime.assert(ctx.sessionID, filePath)
contentOld = await file.text()
contentOld = await Filesystem.readText(filePath)
contentNew = replace(contentOld, params.oldString, params.newString, params.replaceAll)
diff = trimDiff(
@@ -94,7 +93,7 @@ export const EditTool = Tool.define("edit", {
},
})
await file.write(contentNew)
await Filesystem.write(filePath, contentNew)
await Bus.publish(File.Event.Edited, {
file: filePath,
})
@@ -102,7 +101,7 @@ export const EditTool = Tool.define("edit", {
file: filePath,
event: "change",
})
contentNew = await file.text()
contentNew = await Filesystem.readText(filePath)
diff = trimDiff(
createTwoFilesPatch(filePath, filePath, normalizeLineEndings(contentOld), normalizeLineEndings(contentNew)),
)
+2 -4
View File
@@ -1,6 +1,7 @@
import z from "zod"
import path from "path"
import { Tool } from "./tool"
import { Filesystem } from "../util/filesystem"
import DESCRIPTION from "./glob.txt"
import { Ripgrep } from "../file/ripgrep"
import { Instance } from "../project/instance"
@@ -45,10 +46,7 @@ export const GlobTool = Tool.define("glob", {
break
}
const full = path.resolve(search, file)
const stats = await Bun.file(full)
.stat()
.then((x) => x.mtime.getTime())
.catch(() => 0)
const stats = Filesystem.stat(full)?.mtime.getTime() ?? 0
files.push({
path: full,
mtime: stats,
+2 -2
View File
@@ -1,5 +1,6 @@
import z from "zod"
import { Tool } from "./tool"
import { Filesystem } from "../util/filesystem"
import { Ripgrep } from "../file/ripgrep"
import DESCRIPTION from "./grep.txt"
@@ -83,8 +84,7 @@ export const GrepTool = Tool.define("grep", {
const lineNum = parseInt(lineNumStr, 10)
const lineText = lineTextParts.join("|")
const file = Bun.file(filePath)
const stats = await file.stat().catch(() => null)
const stats = Filesystem.stat(filePath)
if (!stats) continue
matches.push({
+2 -1
View File
@@ -6,6 +6,7 @@ import DESCRIPTION from "./lsp.txt"
import { Instance } from "../project/instance"
import { pathToFileURL } from "url"
import { assertExternalDirectory } from "./external-directory"
import { Filesystem } from "../util/filesystem"
const operations = [
"goToDefinition",
@@ -47,7 +48,7 @@ export const LspTool = Tool.define("lsp", {
const relPath = path.relative(Instance.worktree, file)
const title = `${args.operation} ${relPath}:${args.line}:${args.character}`
const exists = await Bun.file(file).exists()
const exists = await Filesystem.exists(file)
if (!exists) {
throw new Error(`File not found: ${file}`)
}
+83 -51
View File
@@ -1,18 +1,22 @@
import z from "zod"
import * as fs from "fs"
import { createReadStream } from "fs"
import * as fs from "fs/promises"
import * as path from "path"
import { createInterface } from "readline"
import { Tool } from "./tool"
import { LSP } from "../lsp"
import { FileTime } from "../file/time"
import DESCRIPTION from "./read.txt"
import { Instance } from "../project/instance"
import { Identifier } from "../id/id"
import { assertExternalDirectory } from "./external-directory"
import { InstructionPrompt } from "../session/instruction"
import { Filesystem } from "../util/filesystem"
const DEFAULT_READ_LIMIT = 2000
const MAX_LINE_LENGTH = 2000
const MAX_LINE_SUFFIX = `... (line truncated to ${MAX_LINE_LENGTH} chars)`
const MAX_BYTES = 50 * 1024
const MAX_BYTES_LABEL = `${MAX_BYTES / 1024} KB`
export const ReadTool = Tool.define("read", {
description: DESCRIPTION,
@@ -31,8 +35,7 @@ export const ReadTool = Tool.define("read", {
}
const title = path.relative(Instance.worktree, filepath)
const file = Bun.file(filepath)
const stat = await file.stat().catch(() => undefined)
const stat = Filesystem.stat(filepath)
await assertExternalDirectory(ctx, filepath, {
bypass: Boolean(ctx.extra?.["bypassCwdCheck"]),
@@ -50,14 +53,18 @@ export const ReadTool = Tool.define("read", {
const dir = path.dirname(filepath)
const base = path.basename(filepath)
const dirEntries = fs.readdirSync(dir)
const suggestions = dirEntries
.filter(
(entry) =>
entry.toLowerCase().includes(base.toLowerCase()) || base.toLowerCase().includes(entry.toLowerCase()),
const suggestions = await fs
.readdir(dir)
.then((entries) =>
entries
.filter(
(entry) =>
entry.toLowerCase().includes(base.toLowerCase()) || base.toLowerCase().includes(entry.toLowerCase()),
)
.map((entry) => path.join(dir, entry))
.slice(0, 3),
)
.map((entry) => path.join(dir, entry))
.slice(0, 3)
.catch(() => [])
if (suggestions.length > 0) {
throw new Error(`File not found: ${filepath}\n\nDid you mean one of these?\n${suggestions.join("\n")}`)
@@ -67,12 +74,12 @@ export const ReadTool = Tool.define("read", {
}
if (stat.isDirectory()) {
const dirents = await fs.promises.readdir(filepath, { withFileTypes: true })
const dirents = await fs.readdir(filepath, { withFileTypes: true })
const entries = await Promise.all(
dirents.map(async (dirent) => {
if (dirent.isDirectory()) return dirent.name + "/"
if (dirent.isSymbolicLink()) {
const target = await fs.promises.stat(path.join(filepath, dirent.name)).catch(() => undefined)
const target = await fs.stat(path.join(filepath, dirent.name)).catch(() => undefined)
if (target?.isDirectory()) return dirent.name + "/"
}
return dirent.name
@@ -111,11 +118,10 @@ export const ReadTool = Tool.define("read", {
const instructions = await InstructionPrompt.resolve(ctx.messages, filepath, ctx.messageID)
// Exclude SVG (XML-based) and vnd.fastbidsheet (.fbs extension, commonly FlatBuffers schema files)
const isImage =
file.type.startsWith("image/") && file.type !== "image/svg+xml" && file.type !== "image/vnd.fastbidsheet"
const isPdf = file.type === "application/pdf"
const mime = Filesystem.mimeType(filepath)
const isImage = mime.startsWith("image/") && mime !== "image/svg+xml" && mime !== "image/vnd.fastbidsheet"
const isPdf = mime === "application/pdf"
if (isImage || isPdf) {
const mime = file.type
const msg = `${isImage ? "Image" : "PDF"} read successfully`
return {
title,
@@ -127,38 +133,61 @@ export const ReadTool = Tool.define("read", {
},
attachments: [
{
id: Identifier.ascending("part"),
sessionID: ctx.sessionID,
messageID: ctx.messageID,
type: "file",
mime,
url: `data:${mime};base64,${Buffer.from(await file.bytes()).toString("base64")}`,
url: `data:${mime};base64,${Buffer.from(await Filesystem.readBytes(filepath)).toString("base64")}`,
},
],
}
}
const isBinary = await isBinaryFile(filepath, file)
const isBinary = await isBinaryFile(filepath, Number(stat.size))
if (isBinary) throw new Error(`Cannot read binary file: ${filepath}`)
const stream = createReadStream(filepath, { encoding: "utf8" })
const rl = createInterface({
input: stream,
// Note: we use the crlfDelay option to recognize all instances of CR LF
// ('\r\n') in file as a single line break.
crlfDelay: Infinity,
})
const limit = params.limit ?? DEFAULT_READ_LIMIT
const offset = params.offset ?? 1
const start = offset - 1
const lines = await file.text().then((text) => text.split("\n"))
if (start >= lines.length) throw new Error(`Offset ${offset} is out of range for this file (${lines.length} lines)`)
const raw: string[] = []
let bytes = 0
let lines = 0
let truncatedByBytes = false
for (let i = start; i < Math.min(lines.length, start + limit); i++) {
const line = lines[i].length > MAX_LINE_LENGTH ? lines[i].substring(0, MAX_LINE_LENGTH) + "..." : lines[i]
const size = Buffer.byteLength(line, "utf-8") + (raw.length > 0 ? 1 : 0)
if (bytes + size > MAX_BYTES) {
truncatedByBytes = true
break
let hasMoreLines = false
try {
for await (const text of rl) {
lines += 1
if (lines <= start) continue
if (raw.length >= limit) {
hasMoreLines = true
continue
}
const line = text.length > MAX_LINE_LENGTH ? text.substring(0, MAX_LINE_LENGTH) + MAX_LINE_SUFFIX : text
const size = Buffer.byteLength(line, "utf-8") + (raw.length > 0 ? 1 : 0)
if (bytes + size > MAX_BYTES) {
truncatedByBytes = true
hasMoreLines = true
break
}
raw.push(line)
bytes += size
}
raw.push(line)
bytes += size
} finally {
rl.close()
stream.destroy()
}
if (lines < offset && !(lines === 0 && offset === 1)) {
throw new Error(`Offset ${offset} is out of range for this file (${lines} lines)`)
}
const content = raw.map((line, index) => {
@@ -169,15 +198,15 @@ export const ReadTool = Tool.define("read", {
let output = [`<path>${filepath}</path>`, `<type>file</type>`, "<content>"].join("\n")
output += content.join("\n")
const totalLines = lines.length
const totalLines = lines
const lastReadLine = offset + raw.length - 1
const hasMoreLines = totalLines > lastReadLine
const nextOffset = lastReadLine + 1
const truncated = hasMoreLines || truncatedByBytes
if (truncatedByBytes) {
output += `\n\n(Output truncated at ${MAX_BYTES} bytes. Use 'offset' parameter to read beyond line ${lastReadLine})`
output += `\n\n(Output capped at ${MAX_BYTES_LABEL}. Showing lines ${offset}-${lastReadLine}. Use offset=${nextOffset} to continue.)`
} else if (hasMoreLines) {
output += `\n\n(File has more lines. Use 'offset' parameter to read beyond line ${lastReadLine})`
output += `\n\n(Showing lines ${offset}-${lastReadLine} of ${totalLines}. Use offset=${nextOffset} to continue.)`
} else {
output += `\n\n(End of file - total ${totalLines} lines)`
}
@@ -203,7 +232,7 @@ export const ReadTool = Tool.define("read", {
},
})
async function isBinaryFile(filepath: string, file: Bun.BunFile): Promise<boolean> {
async function isBinaryFile(filepath: string, fileSize: number): Promise<boolean> {
const ext = path.extname(filepath).toLowerCase()
// binary check for common non-text extensions
switch (ext) {
@@ -240,22 +269,25 @@ async function isBinaryFile(filepath: string, file: Bun.BunFile): Promise<boolea
break
}
const stat = await file.stat()
const fileSize = stat.size
if (fileSize === 0) return false
const bufferSize = Math.min(4096, fileSize)
const buffer = await file.arrayBuffer()
if (buffer.byteLength === 0) return false
const bytes = new Uint8Array(buffer.slice(0, bufferSize))
const fh = await fs.open(filepath, "r")
try {
const sampleSize = Math.min(4096, fileSize)
const bytes = Buffer.alloc(sampleSize)
const result = await fh.read(bytes, 0, sampleSize, 0)
if (result.bytesRead === 0) return false
let nonPrintableCount = 0
for (let i = 0; i < bytes.length; i++) {
if (bytes[i] === 0) return true
if (bytes[i] < 9 || (bytes[i] > 13 && bytes[i] < 32)) {
nonPrintableCount++
let nonPrintableCount = 0
for (let i = 0; i < result.bytesRead; i++) {
if (bytes[i] === 0) return true
if (bytes[i] < 9 || (bytes[i] > 13 && bytes[i] < 32)) {
nonPrintableCount++
}
}
// If >30% non-printable characters, consider it binary
return nonPrintableCount / result.bytesRead > 0.3
} finally {
await fh.close()
}
// If >30% non-printable characters, consider it binary
return nonPrintableCount / bytes.length > 0.3
}
+6 -3
View File
@@ -27,16 +27,18 @@ import { LspTool } from "./lsp"
import { Truncate } from "./truncation"
import { PlanExitTool, PlanEnterTool } from "./plan"
import { ApplyPatchTool } from "./apply_patch"
import { Glob } from "../util/glob"
export namespace ToolRegistry {
const log = Log.create({ service: "tool.registry" })
export const state = Instance.state(async () => {
const custom = [] as Tool.Info[]
const glob = new Bun.Glob("{tool,tools}/*.{js,ts}")
const matches = await Config.directories().then((dirs) =>
dirs.flatMap((dir) => [...glob.scanSync({ cwd: dir, absolute: true, followSymlinks: true, dot: true })]),
dirs.flatMap((dir) =>
Glob.scanSync("{tool,tools}/*.{js,ts}", { cwd: dir, absolute: true, dot: true, symlink: true }),
),
)
if (matches.length) await Config.waitForDependencies()
for (const match of matches) {
@@ -94,10 +96,11 @@ export namespace ToolRegistry {
async function all(): Promise<Tool.Info[]> {
const custom = await state().then((x) => x.custom)
const config = await Config.get()
const question = ["app", "cli", "desktop"].includes(Flag.OPENCODE_CLIENT) || Flag.OPENCODE_ENABLE_QUESTION_TOOL
return [
InvalidTool,
...(["app", "cli", "desktop", "vscode"].includes(Flag.KILO_CLIENT) ? [QuestionTool] : []), // kilocode_change
...(["app", "cli", "desktop", "vscode"].includes(Flag.KILO_CLIENT) && question ? [QuestionTool] : []), // kilocode_change
BashTool,
ReadTool,
GlobTool,
+1 -1
View File
@@ -36,7 +36,7 @@ export namespace Tool {
title: string
metadata: M
output: string
attachments?: MessageV2.FilePart[]
attachments?: Omit<MessageV2.FilePart, "id" | "sessionID" | "messageID">[]
}>
formatValidationError?(error: z.ZodError): string
}>
+4 -3
View File
@@ -5,6 +5,8 @@ import { Identifier } from "../id/id"
import { PermissionNext } from "../permission/next"
import type { Agent } from "../agent/agent"
import { Scheduler } from "../scheduler"
import { Filesystem } from "../util/filesystem"
import { Glob } from "../util/glob"
export namespace Truncate {
export const MAX_LINES = 2000
@@ -33,8 +35,7 @@ export namespace Truncate {
export async function cleanup() {
const cutoff = Identifier.timestamp(Identifier.create("tool", false, Date.now() - RETENTION_MS))
const glob = new Bun.Glob("tool_*")
const entries = await Array.fromAsync(glob.scan({ cwd: DIR, onlyFiles: true })).catch(() => [] as string[])
const entries = await Glob.scan("tool_*", { cwd: DIR, include: "file" }).catch(() => [] as string[])
for (const entry of entries) {
if (Identifier.timestamp(entry) >= cutoff) continue
await fs.unlink(path.join(DIR, entry)).catch(() => {})
@@ -91,7 +92,7 @@ export namespace Truncate {
const id = Identifier.ascending("tool")
const filepath = path.join(DIR, id)
await Bun.write(Bun.file(filepath), text)
await Filesystem.write(filepath, text)
const hint = hasTaskTool(agent)
? `The tool call succeeded but the output was truncated. Full output saved to: ${filepath}\nUse the Task tool to have explore agent process this file with Grep and Read (with offset/limit). Do NOT read the full file yourself - delegate to save context.`
-4
View File
@@ -3,7 +3,6 @@ import { Tool } from "./tool"
import TurndownService from "turndown"
import DESCRIPTION from "./webfetch.txt"
import { abortAfterAny } from "../util/abort"
import { Identifier } from "../id/id"
const MAX_RESPONSE_SIZE = 5 * 1024 * 1024 // 5MB
const DEFAULT_TIMEOUT = 30 * 1000 // 30 seconds
@@ -103,9 +102,6 @@ export const WebFetchTool = Tool.define("webfetch", {
metadata: {},
attachments: [
{
id: Identifier.ascending("part"),
sessionID: ctx.sessionID,
messageID: ctx.messageID,
type: "file",
mime,
url: `data:${mime};base64,${base64Content}`,
+3 -4
View File
@@ -26,9 +26,8 @@ export const WriteTool = Tool.define("write", {
const filepath = path.isAbsolute(params.filePath) ? params.filePath : path.join(Instance.directory, params.filePath)
await assertExternalDirectory(ctx, filepath)
const file = Bun.file(filepath)
const exists = await file.exists()
const contentOld = exists ? await file.text() : ""
const exists = await Filesystem.exists(filepath)
const contentOld = exists ? await Filesystem.readText(filepath) : ""
if (exists) await FileTime.assert(ctx.sessionID, filepath)
const diff = trimDiff(createTwoFilesPatch(filepath, filepath, contentOld, params.content))
@@ -42,7 +41,7 @@ export const WriteTool = Tool.define("write", {
},
})
await Bun.write(filepath, params.content)
await Filesystem.write(filepath, params.content)
await Bus.publish(File.Event.Edited, {
file: filepath,
})
+101 -17
View File
@@ -1,18 +1,104 @@
import { chmod, mkdir, readFile, writeFile } from "fs/promises"
import { createWriteStream, existsSync, statSync } from "fs"
import { lookup } from "mime-types"
import { realpathSync } from "fs"
import { dirname, join, relative } from "path"
import { Readable } from "stream"
import { pipeline } from "stream/promises"
import { Glob } from "./glob"
export namespace Filesystem {
export const exists = (p: string) =>
Bun.file(p)
.stat()
.then(() => true)
.catch(() => false)
// Fast sync version for metadata checks
export async function exists(p: string): Promise<boolean> {
return existsSync(p)
}
export async function isDir(p: string): Promise<boolean> {
try {
return statSync(p).isDirectory()
} catch {
return false
}
}
export function stat(p: string): ReturnType<typeof statSync> | undefined {
return statSync(p, { throwIfNoEntry: false }) ?? undefined
}
export async function size(p: string): Promise<number> {
const s = stat(p)?.size ?? 0
return typeof s === "bigint" ? Number(s) : s
}
export async function readText(p: string): Promise<string> {
return readFile(p, "utf-8")
}
export async function readJson<T = any>(p: string): Promise<T> {
return JSON.parse(await readFile(p, "utf-8"))
}
export async function readBytes(p: string): Promise<Buffer> {
return readFile(p)
}
export async function readArrayBuffer(p: string): Promise<ArrayBuffer> {
const buf = await readFile(p)
return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength) as ArrayBuffer
}
function isEnoent(e: unknown): e is { code: "ENOENT" } {
return typeof e === "object" && e !== null && "code" in e && (e as { code: string }).code === "ENOENT"
}
export async function write(p: string, content: string | Buffer | Uint8Array, mode?: number): Promise<void> {
try {
if (mode) {
await writeFile(p, content, { mode })
} else {
await writeFile(p, content)
}
} catch (e) {
if (isEnoent(e)) {
await mkdir(dirname(p), { recursive: true })
if (mode) {
await writeFile(p, content, { mode })
} else {
await writeFile(p, content)
}
return
}
throw e
}
}
export async function writeJson(p: string, data: unknown, mode?: number): Promise<void> {
return write(p, JSON.stringify(data, null, 2), mode)
}
export async function writeStream(
p: string,
stream: ReadableStream<Uint8Array> | Readable,
mode?: number,
): Promise<void> {
const dir = dirname(p)
if (!existsSync(dir)) {
await mkdir(dir, { recursive: true })
}
const nodeStream = stream instanceof ReadableStream ? Readable.fromWeb(stream as any) : stream
const writeStream = createWriteStream(p)
await pipeline(nodeStream, writeStream)
if (mode) {
await chmod(p, mode)
}
}
export function mimeType(p: string): string {
return lookup(p) || "application/octet-stream"
}
export const isDir = (p: string) =>
Bun.file(p)
.stat()
.then((s) => s.isDirectory())
.catch(() => false)
/**
* On Windows, normalize a path to its canonical casing using the filesystem.
* This is needed because Windows paths are case-insensitive but LSP servers
@@ -26,6 +112,7 @@ export namespace Filesystem {
return p
}
}
export function overlaps(a: string, b: string) {
const relA = relative(a, b)
const relB = relative(b, a)
@@ -70,16 +157,13 @@ export namespace Filesystem {
const result = []
while (true) {
try {
const glob = new Bun.Glob(pattern)
for await (const match of glob.scan({
const matches = await Glob.scan(pattern, {
cwd: current,
absolute: true,
onlyFiles: true,
followSymlinks: true,
include: "file",
dot: true,
})) {
result.push(match)
}
})
result.push(...matches)
} catch {
// Skip invalid glob patterns
}
+34
View File
@@ -0,0 +1,34 @@
import { glob, globSync, type GlobOptions } from "glob"
import { minimatch } from "minimatch"
export namespace Glob {
export interface Options {
cwd?: string
absolute?: boolean
include?: "file" | "all"
dot?: boolean
symlink?: boolean
}
function toGlobOptions(options: Options): GlobOptions {
return {
cwd: options.cwd,
absolute: options.absolute,
dot: options.dot,
follow: options.symlink ?? false,
nodir: options.include !== "all",
}
}
export async function scan(pattern: string, options: Options = {}): Promise<string[]> {
return glob(pattern, toGlobOptions(options)) as Promise<string[]>
}
export function scanSync(pattern: string, options: Options = {}): string[] {
return globSync(pattern, toGlobOptions(options)) as string[]
}
export function match(pattern: string, filepath: string): boolean {
return minimatch(filepath, pattern, { dot: true })
}
}
+14 -12
View File
@@ -1,7 +1,9 @@
import path from "path"
import fs from "fs/promises"
import { createWriteStream } from "fs"
import { Global } from "../global"
import z from "zod"
import { Glob } from "./glob"
export namespace Log {
export const Level = z.enum(["DEBUG", "INFO", "WARN", "ERROR"]).meta({ ref: "LogLevel", description: "Log level" })
@@ -63,24 +65,24 @@ export namespace Log {
Global.Path.log,
options.dev ? "dev.log" : new Date().toISOString().split(".")[0].replace(/:/g, "") + ".log",
)
const logfile = Bun.file(logpath)
await fs.truncate(logpath).catch(() => {})
const writer = logfile.writer()
const stream = createWriteStream(logpath, { flags: "a" })
write = async (msg: any) => {
const num = writer.write(msg)
writer.flush()
return num
return new Promise((resolve, reject) => {
stream.write(msg, (err) => {
if (err) reject(err)
else resolve(msg.length)
})
})
}
}
async function cleanup(dir: string) {
const glob = new Bun.Glob("????-??-??T??????.log")
const files = await Array.fromAsync(
glob.scan({
cwd: dir,
absolute: true,
}),
)
const files = await Glob.scan("????-??-??T??????.log", {
cwd: dir,
absolute: true,
include: "file",
})
if (files.length <= 5) return
const filesToDelete = files.slice(0, -10)