Merge branch 'main' into mark/inline-tool-registry-overrides

This commit is contained in:
Mark IJbema
2026-05-07 12:26:29 +02:00
committed by GitHub
845 changed files with 38099 additions and 12305 deletions
+20
View File
@@ -1,5 +1,25 @@
# @kilocode/cli
## 7.2.42
### Minor Changes
- [#9909](https://github.com/Kilo-Org/kilocode/pull/9909) [`9ffd047`](https://github.com/Kilo-Org/kilocode/commit/9ffd047962039d6b73d301d5d4e67560cd501c4f) - Detect and preserve UTF-32 (LE and BE) with BOM when reading and editing files. UTF-16 and UTF-32 without a BOM remain unsupported.
### Patch Changes
- [#9887](https://github.com/Kilo-Org/kilocode/pull/9887) [`d9453f0`](https://github.com/Kilo-Org/kilocode/commit/d9453f0da2b063041f6f98235220cde9129e162d) - Fix queued-turn auto-compaction so overflow recovery runs instead of exhausting compaction attempts.
- [#9855](https://github.com/Kilo-Org/kilocode/pull/9855) [`59e8eff`](https://github.com/Kilo-Org/kilocode/commit/59e8effc3df8a03146f5ceddf95f79989b813417) - Respect project-specific semantic indexing decisions instead of enabling indexing globally across workspaces.
- [#9928](https://github.com/Kilo-Org/kilocode/pull/9928) [`520922f`](https://github.com/Kilo-Org/kilocode/commit/520922ff39354c2df72317dee0f70035c52c24c5) Thanks [@shssoichiro](https://github.com/shssoichiro)! - Prevent VS Code empty windows from starting codebase indexing against the home directory.
- [#9843](https://github.com/Kilo-Org/kilocode/pull/9843) [`27d14d4`](https://github.com/Kilo-Org/kilocode/commit/27d14d432c33051e4bdd5863ea14b207758e9234) - Prompt before reading `.env` files even after broad read permissions were previously approved.
- [#9924](https://github.com/Kilo-Org/kilocode/pull/9924) [`914bbdf`](https://github.com/Kilo-Org/kilocode/commit/914bbdfd0575e40554c39c6691e4264a63109953) Thanks [@shssoichiro](https://github.com/shssoichiro)! - Restore Skill tool access for Plan, Ask, Explore, and other non-system agents so skill workflows are available by default.
- [#9907](https://github.com/Kilo-Org/kilocode/pull/9907) [`d9d4dcd`](https://github.com/Kilo-Org/kilocode/commit/d9d4dcd37c6719652252da66b6a1ce27049beb47) - Recover sessions left unable to continue after an assistant turn was created but never started.
## 7.2.39
### Patch Changes
+2 -4
View File
@@ -1,12 +1,11 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "7.2.40",
"version": "7.2.42",
"name": "@kilocode/cli",
"type": "module",
"license": "MIT",
"private": true,
"scripts": {
"prepare": "effect-language-service patch || true",
"typecheck": "tsgo --noEmit",
"test": "bun run script/test-runner.ts",
"test:ci": "bun run script/test-runner.ts --ci",
@@ -142,6 +141,7 @@
"ai-gateway-provider": "3.1.2",
"bonjour-service": "1.3.0",
"bun-pty": "0.4.8",
"chardet": "2.1.1",
"chokidar": "4.0.3",
"cli-sound": "1.1.3",
"clipboardy": "4.0.0",
@@ -160,7 +160,6 @@
"iconv-lite": "0.7.2",
"ignore": "7.0.5",
"immer": "11.1.4",
"jschardet": "3.1.4",
"jsonc-parser": "3.3.1",
"mime-types": "3.0.2",
"minimatch": "10.2.5",
@@ -176,7 +175,6 @@
"semver": "^7.6.3",
"simple-git": "3.35.2",
"solid-js": "catalog:",
"stream-chat": "9.38.0",
"strip-ansi": "7.1.2",
"tree-sitter-bash": "0.25.0",
"tree-sitter-powershell": "0.25.10",
+1
View File
@@ -53,6 +53,7 @@ console.log(`Loaded ${migrations.length} migrations`)
const singleFlag = process.argv.includes("--single")
const baselineFlag = process.argv.includes("--baseline")
const skipInstall = process.argv.includes("--skip-install")
const sourcemapsFlag = process.argv.includes("--sourcemaps")
const plugin = createSolidTransformPlugin()
// kilocode_change - packages/app was removed; the web UI embed step is no longer applicable
+100 -36
View File
@@ -25,9 +25,10 @@ if (argv.includes("--help") || argv.includes("-h")) {
"",
"Options:",
" --ci Enable JUnit XML output to .artifacts/unit/junit.xml",
" --concurrency <N> Max parallel processes (default: CPU count)",
" --timeout <ms> Per-test timeout passed to bun test (default: 30000)",
" --concurrency <N> Max parallel processes (default: min(4, CPU count))",
" --timeout <ms> Per-test timeout passed to bun test (default: 60000)",
" --file-timeout <ms> Per-file process timeout (default: 300000)",
" --retries <N> Extra attempts for failing files (default: 1)",
" --bail Stop on first failure",
" --verbose Show full output for every file",
" -h, --help Show this help",
@@ -52,11 +53,15 @@ function opt(name: string, fallback: number) {
const ci = argv.includes("--ci")
const bail = argv.includes("--bail")
const verbose = argv.includes("--verbose")
const concurrency = opt("concurrency", os.cpus().length)
const timeout = opt("timeout", 30000)
// Cap concurrency at 4 even on bigger runners: the bottleneck is shared
// resources (ports, global filesystem like ~/.local/share/kilo), not CPU.
// Eight parallel processes was triggering port/FS races, not going faster.
const concurrency = opt("concurrency", Math.min(4, os.cpus().length))
const timeout = opt("timeout", 60000)
const deadline = opt("file-timeout", 300000)
const retries = opt("retries", 1)
const valued = new Set(["--concurrency", "--timeout", "--file-timeout"])
const valued = new Set(["--concurrency", "--timeout", "--file-timeout", "--retries"])
const patterns = argv.filter((arg, i) => {
if (arg.startsWith("-")) return false
if (i > 0 && valued.has(argv[i - 1])) return false
@@ -70,6 +75,7 @@ const patterns = argv.filter((arg, i) => {
const tty = !!process.stdout.isTTY
const green = (s: string) => (tty ? `\x1b[32m${s}\x1b[0m` : s)
const red = (s: string) => (tty ? `\x1b[31m${s}\x1b[0m` : s)
const yellow = (s: string) => (tty ? `\x1b[33m${s}\x1b[0m` : s)
const dim = (s: string) => (tty ? `\x1b[2m${s}\x1b[0m` : s)
const bold = (s: string) => (tty ? `\x1b[1m${s}\x1b[0m` : s)
@@ -80,8 +86,15 @@ const bold = (s: string) => (tty ? `\x1b[1m${s}\x1b[0m` : s)
const glob = new Bun.Glob("**/*.test.{ts,tsx}")
const all = (await Array.fromAsync(glob.scan({ cwd: path.join(root, "test") }))).sort()
const files =
export const skipped = new Set([
// Upstream browser OAuth integration tests bind the fixed callback port and
// race with other parallel OAuth tests in CI.
"mcp/oauth-browser.test.ts",
])
const matched =
patterns.length > 0 ? all.filter((f) => patterns.some((p) => f.includes(p) || path.join("test", f).includes(p))) : all
const files = patterns.length > 0 ? matched : matched.filter((f) => !skipped.has(f)) // kilocode_change
if (files.length === 0) {
console.log("No test files found")
@@ -100,6 +113,7 @@ type Result = {
stderr: string
duration: number
timedout: boolean
attempts: number
}
// ---------------------------------------------------------------------------
@@ -155,6 +169,7 @@ async function run(file: string): Promise<Result> {
stderr,
duration: performance.now() - start,
timedout: killed.value,
attempts: 1,
}
}
@@ -166,21 +181,28 @@ function report(result: Result) {
counter.done++
const idx = String(counter.done).padStart(pad)
const secs = (result.duration / 1000).toFixed(1)
const tries = result.attempts > 1 ? dim(` [attempt ${result.attempts}/${retries + 1}]`) : ""
if (result.timedout) {
console.log(
`[${idx}/${files.length}] ${red("TIME")} ${result.file} ${dim(`(${secs}s - exceeded ${deadline / 1000}s)`)}`,
`[${idx}/${files.length}] ${red("TIME")} ${result.file} ${dim(`(${secs}s - exceeded ${deadline / 1000}s)`)}${tries}`,
)
return
}
if (!result.passed) {
console.log(`[${idx}/${files.length}] ${red("FAIL")} ${result.file} ${dim(`(${secs}s)`)}`)
console.log(`[${idx}/${files.length}] ${red("FAIL")} ${result.file} ${dim(`(${secs}s)`)}${tries}`)
if (verbose && result.stderr.trim()) console.log(result.stderr)
if (verbose && result.stdout.trim()) console.log(result.stdout)
return
}
if (result.attempts > 1) {
console.log(`[${idx}/${files.length}] ${yellow("FLAKY")} ${result.file} ${dim(`(${secs}s)`)}${tries}`)
if (verbose && result.stdout.trim()) console.log(dim(result.stdout))
return
}
console.log(`[${idx}/${files.length}] ${green("PASS")} ${result.file} ${dim(`(${secs}s)`)}`)
if (verbose && result.stdout.trim()) console.log(dim(result.stdout))
}
@@ -199,7 +221,16 @@ const stopped = { value: false }
const workers = Array.from({ length: Math.min(concurrency, files.length) }, async () => {
while (queue.length > 0 && !stopped.value) {
const file = queue.shift()!
const result = await run(file)
let result = await run(file)
// Retry failing files up to `retries` extra times. Bugs still fail on every
// attempt; contention-based flakes (port races, slow FS, slow spawn) recover.
// Preserve the last attempt's stdout/stderr/duration so a truly broken file
// still shows a useful diagnostic.
while (!result.passed && result.attempts <= retries && !stopped.value) {
const retry = await run(file)
retry.attempts = result.attempts + 1
result = retry
}
results.push(result)
report(result)
if (bail && !result.passed) stopped.value = true
@@ -238,14 +269,51 @@ if (failures.length > 0 && !verbose) {
// ---------------------------------------------------------------------------
const passed = results.filter((r) => r.passed).length
const flaky = results.filter((r) => r.passed && r.attempts > 1)
console.log(
`\n${bold(String(results.length))} files | ` +
`${green(passed + " passed")} | ` +
`${failures.length > 0 ? red(failures.length + " failed") : failures.length + " failed"} | ` +
`${flaky.length > 0 ? yellow(flaky.length + " flaky") : flaky.length + " flaky"} | ` +
`${elapsed.toFixed(1)}s\n`,
)
if (flaky.length > 0) {
const sorted = flaky.slice().sort((a, b) => a.file.localeCompare(b.file))
console.log(`${bold(yellow("--- FLAKY (passed on retry) ---"))}\n`)
for (const r of sorted) {
console.log(` ${yellow(r.file)} ${dim(`(passed on attempt ${r.attempts}/${retries + 1})`)}`)
}
console.log()
// Surface flakies to the GitHub Actions UI so reviewers don't have to scan
// the raw log. Annotations show up on the PR; the step summary is visible at
// the bottom of the job page and in the workflow summary email.
if (process.env.GITHUB_ACTIONS === "true") {
for (const r of sorted) {
const repo = `packages/opencode/test/${r.file}`
console.log(`::warning file=${repo},title=Flaky test file::passed on attempt ${r.attempts} of ${retries + 1}`)
}
const summary = process.env.GITHUB_STEP_SUMMARY
if (summary) {
const md = [
"### ⚠️ Flaky test files (passed on retry)",
"",
`${sorted.length} file${sorted.length === 1 ? "" : "s"} needed more than one attempt to pass.`,
"",
"| File | Attempts |",
"|---|---|",
...sorted.map((r) => `| \`${r.file}\` | ${r.attempts}/${retries + 1} |`),
"",
].join("\n")
await fs.appendFile(summary, md + "\n")
}
}
}
// ---------------------------------------------------------------------------
// JUnit XML merge (CI mode)
// ---------------------------------------------------------------------------
@@ -280,11 +348,15 @@ async function merge() {
const extracted = extract(content)
if (extracted) {
suites.push(extracted)
// kilocode_change start - count nested suites without matching the root testsuites tag.
counts.tests += sum(extracted, "tests")
counts.failures += sum(extracted, "failures")
counts.errors += sum(extracted, "errors")
// kilocode_change end
// Counts come from the outer <testsuites ...> root attributes, not from
// regex-scanning the inner content, so nested <testsuite> blocks (bun
// emits one per `describe`) don't get double-counted.
const root = content.match(/<testsuites\b([^>]*)>/)
if (root) {
counts.tests += attr(root[1], "tests")
counts.failures += attr(root[1], "failures")
counts.errors += attr(root[1], "errors")
}
continue
}
}
@@ -321,32 +393,24 @@ async function merge() {
await Bun.write(path.join(dir, "junit.xml"), body)
}
function extract(content: string, from = 0): string {
const close = "</testsuite>"
const s = open(content, from) // kilocode_change
if (s === -1) return ""
const e = content.indexOf(close, s)
if (e === -1) return ""
const suite = content.slice(s, e + close.length)
const rest = extract(content, e + close.length)
return rest ? suite + "\n" + rest : suite
// Grab everything between the outer <testsuites ...> and </testsuites> of a
// per-file JUnit XML. Preserves nested <testsuite> blocks verbatim — the
// previous hand-rolled walker matched the first </testsuite> it found, which
// closed an inner suite and left the outer one dangling in the merged output.
function extract(content: string): string {
const open = content.match(/<testsuites\b[^>]*>/)
if (!open) return ""
const start = open.index! + open[0].length
const end = content.lastIndexOf("</testsuites>")
if (end === -1 || end <= start) return ""
return content.slice(start, end).trim()
}
// kilocode_change start
function open(content: string, from: number): number {
const tag = "<testsuite"
const s = content.indexOf(tag, from)
if (s === -1) return -1
const ch = content[s + tag.length]
if (ch === ">" || /\s/.test(ch)) return s
return open(content, s + 1)
function attr(attrs: string, name: string): number {
const m = attrs.match(new RegExp(`\\b${name}="(\\d+)"`))
return m ? Number(m[1]) : 0
}
function sum(content: string, name: string): number {
return Array.from(content.matchAll(new RegExp(`${name}="(\\d+)"`, "g"))).reduce((n, m) => n + Number(m[1]), 0)
}
// kilocode_change end
function esc(s: string): string {
return s
.replace(/&/g, "&amp;")
+52
View File
@@ -0,0 +1,52 @@
#!/bin/bash
# Compare SDK types generated from Hono vs HttpApi specs.
# Sorts types alphabetically so only meaningful body differences show.
#
# Usage: ./scripts/diff-sdk-types.sh # full diff
# ./scripts/diff-sdk-types.sh --stat # summary only
set -euo pipefail
DIR="$(cd "$(dirname "$0")/.." && pwd)"
SDK="$(cd "$DIR/../sdk/js" && pwd)"
normalize() {
python3 -c "
import re, sys
content = open(sys.argv[1]).read()
blocks = re.split(r'(?=^export (?:type|function|const) )', content, flags=re.MULTILINE)
header, body = blocks[0], blocks[1:]
body.sort(key=lambda b: m.group(1) if (m := re.match(r'export \w+ (\w+)', b)) else '')
sys.stdout.write(header + ''.join(body))
" "$1"
}
echo "Generating Hono SDK..." >&2
(cd "$SDK" && bun run script/build.ts >/dev/null 2>&1)
normalize "$SDK/src/v2/gen/types.gen.ts" > /tmp/sdk-types-hono.ts
git -C "$SDK" checkout -- src/ 2>/dev/null
echo "Generating HttpApi SDK..." >&2
(cd "$SDK" && OPENCODE_SDK_OPENAPI=httpapi bun run script/build.ts >/dev/null 2>&1)
normalize "$SDK/src/v2/gen/types.gen.ts" > /tmp/sdk-types-httpapi.ts
git -C "$SDK" checkout -- src/ 2>/dev/null
echo "" >&2
if [[ "${1:-}" == "--stat" ]]; then
diff_output=$(diff /tmp/sdk-types-hono.ts /tmp/sdk-types-httpapi.ts || true)
honly=$(printf "%s\n" "$diff_output" | grep -c '^< export type' || true)
aonly=$(printf "%s\n" "$diff_output" | grep -c '^> export type' || true)
total=$(printf "%s\n" "$diff_output" | wc -l | tr -d ' ')
echo "Hono-only: $honly types HttpApi-only: $aonly types Diff lines: $total"
echo ""
if [[ $honly -gt 0 ]]; then
echo "=== Hono-only types ==="
printf "%s\n" "$diff_output" | grep '^< export type' | sed 's/< export type //' | sed 's/[ =].*//' | sed 's/^/ /'
echo ""
fi
if [[ $aonly -gt 0 ]]; then
echo "=== HttpApi-only types ==="
printf "%s\n" "$diff_output" | grep '^> export type' | sed 's/> export type //' | sed 's/[ =].*//' | sed 's/^/ /'
fi
else
diff /tmp/sdk-types-hono.ts /tmp/sdk-types-httpapi.ts || true
fi
+38 -18
View File
@@ -12,14 +12,16 @@ Plan for replacing instance Hono route implementations with Effect `HttpApi` whi
## Current State
- `KILO_EXPERIMENTAL_HTTPAPI` gates the bridge. Default behavior still uses Hono.
- The bridge mounts selected paths in `server/routes/instance/index.ts` before legacy Hono routes.
- Legacy Hono routes remain for default behavior and for `hono-openapi` SDK generation.
- `HttpApi` auth is independent of Hono auth.
- `Authorization` is attached in each route module, not centrally wrapped in `server.ts`.
- `KILO_EXPERIMENTAL_HTTPAPI` selects the backend at server startup. Default is still `hono`.
- `server/backend.ts` picks one of `effect-httpapi` or `hono`; `server.ts` builds either a pure Effect `HttpApi` web handler or the legacy Hono app accordingly. The earlier in-Hono "bridge" model has been replaced by this fork-at-startup.
- Legacy Hono routes remain mounted for the `hono` backend and remain the source for `hono-openapi` SDK generation.
- An Effect `HttpApi` OpenAPI surface exists (`OpenApi.fromApi(PublicApi)` in `cli/cmd/generate.ts --httpapi`, `KILO_SDK_OPENAPI=httpapi` in `packages/sdk/js/script/build.ts`) but is opt-in. The default SDK generation is still Hono.
- `httpapi/public.ts` carries the Hono-compat normalization for the Effect-generated OpenAPI surface (auth scheme strip, request-body required flag, optional `null` arms, `BadRequestError` / `NotFoundError` remap, `$ref` self-cycle fix, `auth_token` query injection). Today's Effect-generated SDK is not byte-identical to the Hono-generated SDK — see Phase 4.
- Auth is centrally configured for the Effect backend via Effect `Config` (`refactor: use Effect config for HttpApi authorization`, `Fix HttpApi raw route authorization`) rather than re-attached in each route module.
- Auth supports Basic auth and the legacy `auth_token` query parameter through `HttpApiSecurity.apiKey`.
- Instance context is provided by `httpapi/server.ts` using `directory`, `workspace`, and `x-kilo-directory`.
- `Observability.layer` is provided in the Effect route layer and deduplicated through the shared `memoMap`.
- CORS middleware is wired into both backends (`feat(httpapi): add CORS middleware to instance routes`).
## Migration Rules
@@ -122,13 +124,30 @@ Keep large or stateful groups for later:
Hono routes cannot be deleted while `hono-openapi` is the source of SDK generation.
Status: the Effect `HttpApi` OpenAPI surface is **implemented and opt-in** (`bun dev generate --httpapi`, `KILO_SDK_OPENAPI=httpapi`). Default SDK generation still uses Hono. `httpapi/public.ts` applies the Hono-compat normalization layer to the Effect output. Diff against the Hono-generated spec still shows real gaps that must be closed before the SDK can flip:
- Branded-type `pattern` constraints on ID schemas are not propagated to the Effect output (~169 missing).
- Per-property `description` annotations are not propagated through `Schema.Struct` to the Effect output (~107 missing).
- `Event.*` and `SyncEvent.*` component names use dotted form in Hono and PascalCase in Effect (~50 differences, breaks SDK type names).
- Effect's component deduper emits numbered duplicates (`Session9`, `SyncEvent.session.updated.11`) that need a name-collision fix.
- Cosmetic-only diffs (`additionalProperties: false`, `const` vs `enum`, MAX_SAFE_INTEGER `maximum`, `propertyNames`) can be normalized in `public.ts` if they would otherwise change SDK output.
Required before route deletion:
- Generate the public OpenAPI surface from Effect `HttpApi` for ported routes.
- Close the diff above so Effect-generated SDK output matches the Hono-generated SDK output for every retained path.
- Keep operation IDs, schemas, status codes, and SDK type names stable unless the change is intentional.
- Flip `packages/sdk/js/script/build.ts` default to `httpapi` and regenerate.
- Compare generated SDK output against `dev` for every route group deletion.
- Remove Hono OpenAPI stubs only after Effect OpenAPI is the SDK source for those paths.
V2 cleanup once SDK compatibility no longer needs the legacy Hono contract:
- Remove `public.ts` compatibility transforms that hide honest `HttpApi` metadata, including auth `securitySchemes`, per-route `security`, and generated `401` responses.
- Stop remapping built-in `HttpApi` error schemas back to legacy Hono `BadRequestError` / `NotFoundError` components if V2 clients can consume the actual Effect error shape.
- Prefer the direct `HttpApi` OpenAPI output for request/response bodies and named component schemas instead of rewriting it to match Hono generator quirks.
- Keep schema fixes that describe the actual wire format, but delete transforms that only preserve legacy SDK type names or inline-vs-ref shape.
- Re-evaluate `auth_token` as an OpenAPI security scheme rather than a hand-injected query parameter once clients can consume the V2 spec.
### 5. Make HttpApi Default For JSON Routes
After JSON parity and SDK generation are covered:
@@ -179,7 +198,7 @@ Use raw Effect HTTP routes where `HttpApi` does not fit. The goal is deleting Ho
| `project` | `bridged` | list, current, git init, update |
| `file` | `bridged` partial | find text/file/symbol, list/content/status |
| `mcp` | `bridged` | status, add, OAuth, connect/disconnect |
| `workspace` | `bridged` | adaptor/list/status/create/remove/session-restore |
| `workspace` | `bridged` | adapter/list/status/create/remove/session-restore |
| top-level instance routes | `bridged` | path, vcs, command, agent, skill, lsp, formatter, dispose |
| experimental JSON routes | `bridged` | console, tool, worktree list/mutations, global session list, resource list |
| `session` | `bridged` | read, lifecycle, prompt, message/part mutations, revert, permission reply |
@@ -271,7 +290,7 @@ This checklist tracks bridge parity only. Checked routes are available through t
### Workspace Routes
- [x] `GET /experimental/workspace/adaptor` - list workspace adaptors.
- [x] `GET /experimental/workspace/adapter` - list workspace adapters.
- [x] `POST /experimental/workspace` - create workspace.
- [x] `GET /experimental/workspace` - list workspaces.
- [x] `GET /experimental/workspace/status` - workspace status.
@@ -357,25 +376,26 @@ Prefer smaller PRs from here so route behavior and SDK/OpenAPI fallout stays rev
8. [x] Bridge session read routes: list, status, get, children, todo, diff, messages.
9. [x] Bridge session lifecycle mutation routes: create, delete, update, fork, abort.
10. [x] Bridge remaining session mutation and prompt routes.
11. [ ] Replace event SSE with non-Hono Effect HTTP.
12. [x] Replace pty websocket/control routes with non-Hono Effect HTTP.
13. [x] Replace tui bridge routes or explicitly isolate them behind a non-Hono compatibility layer.
14. [ ] Switch OpenAPI/SDK generation to Effect routes and compare SDK output.
15. [ ] Flip ported JSON routes default-on, keep a short fallback, then delete replaced Hono route files.
11. [ ] Replace event SSE with non-Hono Effect HTTP. The Effect backend has a raw Effect HTTP `httpapi/event.ts`; the Hono backend still uses `hono/streaming` `streamSSE`. Either port Hono `/event` to raw Effect HTTP for the fallback window, or skip and delete it together with Hono in step 15.
12. [x] Replace pty websocket/control routes with non-Hono Effect HTTP for the Effect backend. Hono `pty.ts` remains in the Hono backend.
13. [x] Replace tui bridge routes or explicitly isolate them behind a non-Hono compatibility layer for the Effect backend. Hono `tui.ts` remains in the Hono backend.
14. [ ] Switch OpenAPI/SDK generation to Effect routes and compare SDK output. Effect path is implemented and opt-in via `--httpapi` / `KILO_SDK_OPENAPI=httpapi`. Close the schema-shape gaps in `public.ts` (branded `pattern`, per-property `description`, `Event.*` / `SyncEvent.*` naming, dedup collisions), then flip `packages/sdk/js/script/build.ts` default.
15. [ ] Flip `backend.ts` default from `hono` to `effect-httpapi`, keep `KILO_EXPERIMENTAL_HTTPAPI` (or its inverse) as a short fallback flag, then delete replaced Hono route files.
## Checklist
- [x] Add first `HttpApi` JSON route slices.
- [x] Bridge selected `HttpApi` routes into Hono behind `KILO_EXPERIMENTAL_HTTPAPI`.
- [x] Bridge selected `HttpApi` routes behind `KILO_EXPERIMENTAL_HTTPAPI`. (Now backend-fork-at-startup rather than in-Hono path mounting.)
- [x] Reuse existing Effect services in handlers.
- [x] Provide auth, instance lookup, and observability in the Effect route layer.
- [x] Attach auth middleware in route modules.
- [x] Centralize auth via Effect `Config` for the Effect backend.
- [x] Support `auth_token` as a query security scheme.
- [x] Add bridge-level auth and instance tests.
- [x] Complete exact Hono route inventory.
- [x] Resolve implemented-but-unmounted route groups.
- [x] Port remaining top-level JSON reads.
- [ ] Generate SDK/OpenAPI from Effect routes.
- [ ] Flip ported JSON routes to default-on with fallback.
- [x] Implement Effect `HttpApi` OpenAPI generation behind `--httpapi` / `KILO_SDK_OPENAPI=httpapi`.
- [ ] Close Effect-vs-Hono OpenAPI schema-shape gaps and flip the SDK generator default.
- [ ] Flip the runtime backend default from `hono` to `effect-httpapi`, with a short fallback flag.
- [ ] Delete replaced Hono route implementations.
- [ ] Replace SSE/websocket/streaming Hono routes with non-Hono implementations.
- [ ] Replace SSE/websocket/streaming Hono routes with non-Hono implementations (or remove with the rest of Hono).
+1 -2
View File
@@ -286,7 +286,6 @@ emitted JSON Schema must stay byte-identical.
- [x] `src/tool/apply_patch.ts`
- [x] `src/tool/bash.ts`
- [x] `src/tool/codesearch.ts`
- [x] `src/tool/edit.ts`
- [x] `src/tool/glob.ts`
- [x] `src/tool/grep.ts`
@@ -354,7 +353,7 @@ piecewise.
- [ ] `src/cli/cmd/tui/event.ts`
- [ ] `src/cli/ui.ts`
- [ ] `src/command/index.ts`
- [x] `src/control-plane/adaptors/worktree.ts`
- [x] `src/control-plane/adapters/worktree.ts`
- [x] `src/control-plane/types.ts`
- [x] `src/control-plane/workspace.ts`
- [ ] `src/file/index.ts`
-2
View File
@@ -40,7 +40,6 @@ These exported tool definitions currently use `Tool.define(...)` in `src/tool`:
- [x] `apply_patch.ts`
- [x] `bash.ts`
- [x] `codesearch.ts`
- [x] `edit.ts`
- [x] `glob.ts`
- [x] `grep.ts`
@@ -79,7 +78,6 @@ Notable items that are already effectively on the target path and do not need se
- `apply_patch.ts`
- `grep.ts`
- `write.ts`
- `codesearch.ts`
- `websearch.ts`
- `edit.ts`
+67
View File
@@ -0,0 +1,67 @@
// @ts-nocheck
import { OpenCode } from "@opencode-ai/core"
import { ReadTool } from "@opencode-ai/core/tools"
const opencode = OpenCode.make({})
opencode.tool.add(ReadTool)
opencode.tool.add({
name: "bash",
schema: {
type: "object",
properties: {
command: {
type: "string",
description: "The command to run.",
},
},
required: ["command"],
},
execute(input, ctx) {},
})
opencode.auth.add({
provider: "openai",
type: "api",
value: process.env.OPENAI_API_KEY,
})
opencode.agent.add({
name: "build",
permissions: [],
model: {
id: "gpt-5-5",
provider: "openai",
variant: "xhigh",
},
})
const sessionID = await opencode.session.create({
agent: "build",
})
opencode.subscribe((event) => {
console.log(event)
})
await opencode.session.prompt({
sessionID,
text: "hey what is up",
})
await opencode.session.prompt({
sessionID,
text: "what is up with this",
files: [
{
mime: "image/png",
uri: "data:image/png;base64,xxxx",
},
],
})
await opencode.session.wait()
console.log(await opencode.session.messages(sessionID))
+1 -1
View File
@@ -1,7 +1,7 @@
import { RequestError, type McpServer } from "@agentclientprotocol/sdk"
import type { ACPSessionState } from "./types"
import type { KiloClient } from "@kilocode/sdk/v2"
import * as Log from "@opencode-ai/core/util/log"
import type { KiloClient } from "@kilocode/sdk/v2"
const log = Log.create({ service: "acp-session-manager" })
+5 -5
View File
@@ -34,8 +34,8 @@ export const Info = Schema.Struct({
mode: Schema.Literals(["subagent", "primary", "all"]),
native: Schema.optional(Schema.Boolean),
hidden: Schema.optional(Schema.Boolean),
topP: Schema.optional(Schema.Number),
temperature: Schema.optional(Schema.Number),
topP: Schema.optional(Schema.Finite),
temperature: Schema.optional(Schema.Finite),
color: Schema.optional(Schema.String),
permission: Permission.Ruleset,
model: Schema.optional(
@@ -47,7 +47,7 @@ export const Info = Schema.Struct({
variant: Schema.optional(Schema.String),
prompt: Schema.optional(Schema.String),
options: Schema.Record(Schema.String, Schema.Unknown),
steps: Schema.optional(Schema.Number),
steps: Schema.optional(Schema.Finite),
})
.annotate({ identifier: "Agent" })
.pipe(withStatics((s) => ({ zod: zod(s) })))
@@ -87,6 +87,7 @@ export const layer = Layer.effect(
// kilocode_change start - include global config dirs so agents can read them without prompting
const whitelistedDirs = [
Truncate.GLOB,
path.join(Global.Path.tmp, "*"),
...skillDirs.map((dir) => path.join(dir, "*")),
path.join(Global.Path.config, "*"),
...KilocodePaths.globalDirs().map((dir) => path.join(dir, "*")),
@@ -187,7 +188,6 @@ export const layer = Layer.effect(
bash: "allow",
webfetch: "allow",
websearch: "allow",
codesearch: "allow",
read: "allow",
external_directory: {
"*": "ask",
@@ -251,7 +251,7 @@ export const layer = Layer.effect(
}
// kilocode_change start - rename build→code, add debug/orchestrator/ask, patch plan/explore
KiloAgent.patchAgents(agents, defaults, user, cfg, kilo)
KiloAgent.patchAgents(agents, defaults, user, cfg, kilo, ctx.worktree, whitelistedDirs)
// kilocode_change end
// kilocode_change start - preprocess config to remap "build" key → "code"
+2 -1
View File
@@ -1,6 +1,7 @@
import path from "path"
import { Effect, Layer, Record, Result, Schema, Context } from "effect"
import { zod } from "@/util/effect-zod"
import { NonNegativeInt } from "@/util/schema"
import { Global } from "@opencode-ai/core/global"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Telemetry } from "@kilocode/kilo-telemetry" // kilocode_change
@@ -16,7 +17,7 @@ export class Oauth extends Schema.Class<Oauth>("OAuth")({
type: Schema.Literal("oauth"),
refresh: Schema.String,
access: Schema.String,
expires: Schema.Number,
expires: NonNegativeInt,
accountId: Schema.optional(Schema.String),
enterpriseUrl: Schema.optional(Schema.String),
}) {}
+12
View File
@@ -34,4 +34,16 @@ export function payloads() {
.toArray()
}
export function effectPayloads() {
return registry
.entries()
.map(([type, def]) =>
Schema.Struct({
type: Schema.Literal(type),
properties: def.properties,
}).annotate({ identifier: `Event.${type}` }),
)
.toArray()
}
export * as BusEvent from "./bus-event"
+4 -4
View File
@@ -1,17 +1,17 @@
import { AppRuntime } from "@/effect/app-runtime"
import { InstanceBootstrap } from "../project/bootstrap"
import { Instance } from "../project/instance"
import { InstanceStore } from "../project/instance-store"
import { getBootstrapRunEffect } from "../effect/app-runtime"
export async function bootstrap<T>(directory: string, cb: () => Promise<T>) {
return Instance.provide({
directory,
init: () => AppRuntime.runPromise(InstanceBootstrap),
init: await getBootstrapRunEffect(),
fn: async () => {
try {
const result = await cb()
return result
} finally {
await Instance.dispose()
await InstanceStore.disposeInstance(Instance.current)
}
},
})
-1
View File
@@ -28,7 +28,6 @@ const AVAILABLE_PERMISSIONS = [
"task",
"todowrite",
"websearch",
"codesearch",
"lsp",
"skill",
]
+1 -4
View File
@@ -244,10 +244,7 @@ export const ExportCommand = cmd({
output: process.stderr,
})
const sessions = []
for await (const session of Session.list()) {
sessions.push(session)
}
const sessions = await AppRuntime.runPromise(Session.Service.use((svc) => svc.list()))
if (sessions.length === 0) {
prompts.log.error("No sessions found", {
+1 -1
View File
@@ -212,7 +212,7 @@ export const GithubInstallCommand = cmd({
const app = await getAppInfo()
await installGitHubApp()
const providers = await ModelsDev.get().then((p) => {
const providers = await AppRuntime.runPromise(ModelsDev.Service.use((s) => s.get())).then((p) => {
// TODO: add guide for copilot, for now just hide it
delete p["github-copilot"]
return p
+41 -62
View File
@@ -1,19 +1,17 @@
import type { Argv } from "yargs"
import { Instance } from "../../project/instance"
import { EOL } from "os"
import { Effect } from "effect"
import { Provider } from "@/provider/provider"
import { ProviderID } from "../../provider/schema"
import { ModelsDev } from "@/provider/models"
import { cmd } from "./cmd"
import { effectCmd, fail } from "../effect-cmd"
import { UI } from "../ui"
import { EOL } from "os"
import { AppRuntime } from "@/effect/app-runtime"
import { Effect } from "effect"
export const ModelsCommand = cmd({
export const ModelsCommand = effectCmd({
command: "models [provider]",
describe: "list all available models",
builder: (yargs: Argv) => {
return yargs
builder: (yargs) =>
yargs
.positional("provider", {
describe: "provider ID to filter models by",
type: "string",
@@ -26,65 +24,46 @@ export const ModelsCommand = cmd({
.option("refresh", {
describe: "refresh the models cache from models.dev",
type: "boolean",
})
},
handler: async (args) => {
}),
handler: Effect.fn("Cli.models")(function* (args) {
if (args.refresh) {
await ModelsDev.refresh(true)
yield* ModelsDev.Service.use((s) => s.refresh(true))
UI.println(UI.Style.TEXT_SUCCESS_BOLD + "Models cache refreshed" + UI.Style.TEXT_NORMAL)
}
await Instance.provide({
directory: process.cwd(),
async fn() {
await AppRuntime.runPromise(
Effect.gen(function* () {
const svc = yield* Provider.Service
const providers = yield* svc.list()
const provider = yield* Provider.Service
const providers = yield* provider.list()
const print = (providerID: ProviderID, verbose?: boolean) => {
const provider = providers[providerID]
const sorted = Object.entries(provider.models).sort(([a], [b]) => a.localeCompare(b))
for (const [modelID, model] of sorted) {
process.stdout.write(`${providerID}/${modelID}`)
process.stdout.write(EOL)
if (verbose) {
process.stdout.write(JSON.stringify(model, null, 2))
process.stdout.write(EOL)
}
}
}
const print = (providerID: ProviderID, verbose?: boolean) => {
const p = providers[providerID]
const sorted = Object.entries(p.models).sort(([a], [b]) => a.localeCompare(b))
for (const [modelID, model] of sorted) {
process.stdout.write(`${providerID}/${modelID}`)
process.stdout.write(EOL)
if (verbose) {
process.stdout.write(JSON.stringify(model, null, 2))
process.stdout.write(EOL)
}
}
}
if (args.provider) {
const providerID = ProviderID.make(args.provider)
const provider = providers[providerID]
if (!provider) {
yield* Effect.sync(() => UI.error(`Provider not found: ${args.provider}`))
return
}
if (args.provider) {
const providerID = ProviderID.make(args.provider)
if (!providers[providerID]) return yield* fail(`Provider not found: ${args.provider}`)
print(providerID, args.verbose)
return
}
yield* Effect.sync(() => print(providerID, args.verbose))
return
}
// kilocode_change start
const ids = Object.keys(providers).sort((a, b) => {
const aIsKilo = a === "kilo" || a.startsWith("opencode")
const bIsKilo = b === "kilo" || b.startsWith("opencode")
if (aIsKilo && !bIsKilo) return -1
if (!aIsKilo && bIsKilo) return 1
return a.localeCompare(b)
})
// kilocode_change end
yield* Effect.sync(() => {
for (const providerID of ids) {
print(ProviderID.make(providerID), args.verbose)
}
})
}),
)
},
// kilocode_change start
const ids = Object.keys(providers).sort((a, b) => {
const aIsKilo = a === "kilo" || a.startsWith("opencode")
const bIsKilo = b === "kilo" || b.startsWith("opencode")
if (aIsKilo && !bIsKilo) return -1
if (!aIsKilo && bIsKilo) return 1
return a.localeCompare(b)
})
},
// kilocode_change end
for (const providerID of ids) print(ProviderID.make(providerID), args.verbose)
}),
})
+38 -23
View File
@@ -4,6 +4,9 @@ import { cmd } from "./cmd"
import * as prompts from "@clack/prompts"
import { UI } from "../ui"
import { ModelsDev } from "@/provider/models"
const getModels = () => AppRuntime.runPromise(ModelsDev.Service.use((s) => s.get()))
const refreshModels = () => AppRuntime.runPromise(ModelsDev.Service.use((s) => s.refresh(true)))
import { map, pipe, sortBy, values } from "remeda"
import path from "path"
import os from "os"
@@ -156,28 +159,38 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string,
}
if (method.type === "api") {
if (method.authorize) {
const key = await prompts.password({
message: "Enter your API key",
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
})
if (prompts.isCancel(key)) throw new UI.CancelledError()
const key = await prompts.password({
message: "Enter your API key",
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
})
if (prompts.isCancel(key)) throw new UI.CancelledError()
const result = await method.authorize(inputs)
if (result.type === "failed") {
prompts.log.error("Failed to authorize")
}
if (result.type === "success") {
const saveProvider = result.provider ?? provider
await put(saveProvider, {
type: "api",
key: result.key ?? key,
})
prompts.log.success("Login successful")
}
const metadata = Object.keys(inputs).length ? { metadata: inputs } : {}
if (!method.authorize) {
await put(provider, {
type: "api",
key,
...metadata,
})
prompts.outro("Done")
return true
}
const result = await method.authorize(inputs)
if (result.type === "failed") {
prompts.log.error("Failed to authorize")
}
if (result.type === "success") {
const saveProvider = result.provider ?? provider
await put(saveProvider, {
type: "api",
key: result.key ?? key,
...metadata,
})
prompts.log.success("Login successful")
}
prompts.outro("Done")
return true
}
return false
@@ -241,7 +254,7 @@ export const ProvidersListCommand = cmd({
return Object.entries(yield* auth.all())
}),
)
const database = await ModelsDev.get()
const database = await getModels()
for (const [providerID, result] of results) {
const name = database[providerID]?.name || providerID
@@ -334,14 +347,14 @@ export const ProvidersLoginCommand = cmd({
prompts.outro("Done")
return
}
await ModelsDev.refresh(true).catch(() => {})
await refreshModels().catch(() => {})
const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.get()))
const disabled = new Set(config.disabled_providers ?? [])
const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined
const providers = await ModelsDev.get().then((x) => {
const providers = await getModels().then((x) => {
const filtered: Record<string, (typeof x)[string]> = {}
for (const [key, value] of Object.entries(x)) {
if ((enabled ? enabled.has(key) : true) && !disabled.has(key)) {
@@ -375,7 +388,9 @@ export const ProvidersLoginCommand = cmd({
existingProviders: providers,
disabled,
enabled,
providerNames: Object.fromEntries(Object.entries(config.provider ?? {}).map(([id, p]) => [id, p.name])),
providerNames: Object.fromEntries(
Object.entries(config.provider ?? {}).flatMap(([id, p]) => (p ? [[id, p.name]] : [])),
), // kilocode_change
})
const options = [
...pipe(
@@ -512,7 +527,7 @@ export const ProvidersLogoutCommand = cmd({
prompts.log.error("No credentials found")
return
}
const database = await ModelsDev.get()
const database = await getModels()
const selected = await prompts.select({
message: "Select provider",
options: credentials.map(([key, value]) => ({
+2 -1
View File
@@ -3,6 +3,7 @@ import { cmd } from "./cmd"
import { bootstrap } from "../bootstrap"
import { KiloSessions } from "@/kilo-sessions/kilo-sessions"
import { Instance } from "@/project/instance"
import { InstanceStore } from "@/project/instance-store"
export const RemoteCommand = cmd({
command: "remote",
@@ -17,7 +18,7 @@ export const RemoteCommand = cmd({
const shutdown = async () => {
try {
KiloSessions.disableRemote()
await Instance.dispose()
await InstanceStore.disposeInstance(Instance.current)
} finally {
abort.abort()
}
-9
View File
@@ -20,7 +20,6 @@ import { ReadTool } from "../../tool/read"
import { WebFetchTool } from "../../tool/webfetch"
import { EditTool } from "../../tool/edit"
import { WriteTool } from "../../tool/write"
import { CodeSearchTool } from "../../tool/codesearch"
import { WebSearchTool } from "../../tool/websearch"
import { TaskTool } from "../../tool/task"
import { SkillTool } from "../../tool/skill"
@@ -147,13 +146,6 @@ function edit(info: ToolProps<typeof EditTool>) {
)
}
function codesearch(info: ToolProps<typeof CodeSearchTool>) {
inline({
icon: "◇",
title: `Exa Code Search "${info.input.query}"`,
})
}
function websearch(info: ToolProps<typeof WebSearchTool>) {
inline({
icon: "◈",
@@ -444,7 +436,6 @@ export const RunCommand = cmd({
if (part.tool === "write") return write(props<typeof WriteTool>(part))
if (part.tool === "webfetch") return webfetch(props<typeof WebFetchTool>(part))
if (part.tool === "edit") return edit(props<typeof EditTool>(part))
if (part.tool === "codesearch") return codesearch(props<typeof CodeSearchTool>(part))
if (part.tool === "websearch") return websearch(props<typeof WebSearchTool>(part))
if (part.tool === "task") return task(props<typeof TaskTool>(part))
if (part.tool === "todowrite") return todo(props<typeof TodoWriteTool>(part))
+2 -2
View File
@@ -2,7 +2,7 @@ import { Server } from "../../server/server"
import { cmd } from "./cmd"
import { withNetworkOptions, resolveNetworkOptions } from "../network"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Instance } from "../../project/instance" // kilocode_change
import { InstanceStore } from "../../project/instance-store" // kilocode_change
export const ServeCommand = cmd({
command: "serve",
@@ -20,7 +20,7 @@ export const ServeCommand = cmd({
const abort = new AbortController()
const shutdown = async () => {
try {
await Instance.disposeAll()
await InstanceStore.disposeAllInstances()
await server.stop(true)
} finally {
abort.abort()
+3 -1
View File
@@ -113,7 +113,9 @@ export const SessionListCommand = cmd({
// kilocode_change start
const sessions = args.all
? [...Session.listGlobal({ roots: true, limit: args.maxCount, search: args.search })]
: [...Session.list({ roots: true, limit: args.maxCount, search: args.search })]
: await AppRuntime.runPromise(
Session.Service.use((svc) => svc.list({ roots: true, limit: args.maxCount, search: args.search })),
)
// kilocode_change end
// kilocode_change start
+30
View File
@@ -140,6 +140,8 @@ export function tui(input: {
process.on("exit", resetTerminalState) // kilocode_change
const renderer = await createCliRenderer(rendererConfig(input.config))
// Prewarm palette before ThemeProvider mounts so `system` theme avoids a first-paint fallback flash.
void renderer.getPalette({ size: 16 }).catch(() => undefined)
const mode = (await renderer.waitForThemeMode(1000)) ?? "dark"
await render(() => {
@@ -310,6 +312,9 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
renderer.clearSelection()
}
const [terminalTitleEnabled, setTerminalTitleEnabled] = createSignal(kv.get("terminal_title_enabled", true))
const [pasteSummaryEnabled, setPasteSummaryEnabled] = createSignal(
kv.get("paste_summary_enabled", !sync.data.config.experimental?.disable_paste_summary),
)
KiloApp.useSessionEffects({ route, sdk, sync }) // kilocode_change
@@ -763,6 +768,31 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
dialog.clear()
},
},
{
title: pasteSummaryEnabled() ? "Disable paste summary" : "Enable paste summary",
value: "app.toggle.paste_summary",
category: "System",
onSelect: (dialog) => {
setPasteSummaryEnabled((prev) => {
const next = !prev
kv.set("paste_summary_enabled", next)
return next
})
dialog.clear()
},
},
{
title: kv.get("session_directory_filter_enabled", true)
? "Disable session directory filtering"
: "Enable session directory filtering",
value: "app.toggle.session_directory_filter",
category: "System",
onSelect: async (dialog) => {
kv.set("session_directory_filter_enabled", !kv.get("session_directory_filter_enabled", true))
await sync.session.refresh()
dialog.clear()
},
},
{
title: kv.get("diff_wrap_mode", "word") === "word" ? "Disable diff wrapping" : "Enable diff wrapping",
value: "app.toggle.diffwrap",
@@ -40,7 +40,7 @@ export function createDialogProviderOptions() {
description: KiloProvider.PROVIDER_DESCRIPTIONS[provider.id], // kilocode_change
footer: consoleManaged ? sync.data.console_state.activeOrgName : undefined,
category: provider.id in PROVIDER_PRIORITY ? "Popular" : "Other",
gutter: connected && onboarded() ? <text fg={theme.success}>✓</text> : undefined,
gutter: connected && onboarded() ? () => <text fg={theme.success}>✓</text> : undefined,
async onSelect() {
if (consoleManaged) return
@@ -35,6 +35,9 @@ export function DialogSessionList() {
const [global, setGlobal] = createSignal(true) // kilocode_change - show all worktrees by default
// kilocode_change start - always fetch from experimental endpoint (returns GlobalSession with worktree info)
// TODO: extend /experimental/session to accept `scope`/`path` so this dialog can respect the
// upstream `session_directory_filter_enabled` KV toggle (via sync.session.query()) while
// keeping worktree grouping. Currently the toggle has no effect here.
const [searchResults, searchActions] = createResource(
() => search(),
async (query) => {
@@ -190,7 +193,7 @@ export function DialogSessionList() {
value: x.id,
category,
footer,
gutter: isWorking ? <Spinner /> : undefined,
gutter: isWorking ? () => <Spinner /> : undefined,
}
})
})
@@ -10,7 +10,7 @@ import { errorMessage } from "@/util/error"
import { useSDK } from "../context/sdk"
import { useToast } from "../ui/toast"
type Adaptor = {
type Adapter = {
type: string
name: string
description: string
@@ -108,26 +108,26 @@ export function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) =
const sdk = useSDK()
const toast = useToast()
const [creating, setCreating] = createSignal<string>()
const [adaptors, setAdaptors] = createSignal<Adaptor[]>()
const [adapters, setAdapters] = createSignal<Adapter[]>()
onMount(() => {
dialog.setSize("medium")
void (async () => {
const dir = sync.path.directory || sdk.directory
const url = new URL("/experimental/workspace/adaptor", sdk.url)
const url = new URL("/experimental/workspace/adapter", sdk.url)
if (dir) url.searchParams.set("directory", dir)
const res = await sdk
.fetch(url)
.then((x) => x.json() as Promise<Adaptor[]>)
.then((x) => x.json() as Promise<Adapter[]>)
.catch(() => undefined)
if (!res) {
toast.show({
message: "Failed to load workspace adaptors",
message: "Failed to load workspace adapters",
variant: "error",
})
return
}
setAdaptors(res)
setAdapters(res)
})()
})
@@ -142,13 +142,13 @@ export function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) =
},
]
}
const list = adaptors()
const list = adapters()
if (!list) {
return [
{
title: "Loading workspaces...",
value: "loading" as const,
description: "Fetching available workspace adaptors",
description: "Fetching available workspace adapters",
},
]
}
@@ -1,4 +1,5 @@
import { BoxRenderable, MouseButton, MouseEvent, RGBA, TextAttributes } from "@opentui/core"
import { useRenderer } from "@opentui/solid"
import { For, createMemo, createSignal, onCleanup, onMount, type JSX } from "solid-js"
import { useTheme, tint } from "@tui/context/theme"
import { KiloLogo } from "./kilo-logo" // kilocode_change
@@ -12,11 +12,12 @@ import { useRoute } from "@tui/context/route"
import { useProject } from "@tui/context/project"
import { useSync } from "@tui/context/sync"
import { useEvent } from "@tui/context/event"
import { useEditorContext } from "@tui/context/editor"
import { editorSelectionKey, useEditorContext, type EditorSelection } from "@tui/context/editor"
import { MessageID, PartID } from "@/session/schema"
import { createStore, produce, unwrap } from "solid-js/store"
import { useKeybind } from "@tui/context/keybind"
import { usePromptHistory, type PromptInfo } from "./history"
import { computePromptTraits } from "./traits"
import { assign } from "./part"
import { usePromptStash } from "./stash"
import { DialogStash } from "../dialog-stash"
@@ -43,6 +44,7 @@ import { DialogSkill } from "../dialog-skill"
import { DialogWorkspaceCreate, restoreWorkspaceSession } from "../dialog-workspace-create"
import { DialogWorkspaceUnavailable } from "../dialog-workspace-unavailable"
import { useArgs } from "@tui/context/args"
import { KiloSessionTuiSync } from "@/kilocode/session/tui-sync" // kilocode_change
export type PromptProps = {
sessionID?: string
@@ -84,6 +86,32 @@ function fadeColor(color: RGBA, alpha: number) {
return RGBA.fromValues(color.r, color.g, color.b, color.a * alpha)
}
function hasEditorRangeSelection(selection: EditorSelection["ranges"][number]) {
return (
selection.selection.start.line !== selection.selection.end.line ||
selection.selection.start.character !== selection.selection.end.character
)
}
function getEditorRangeLabel(selection: EditorSelection["ranges"][number]) {
if (!hasEditorRangeSelection(selection)) return
if (selection.selection.start.line === selection.selection.end.line) return `#${selection.selection.start.line}`
return `#${selection.selection.start.line}-${selection.selection.end.line}`
}
function formatEditorContext(selection: EditorSelection) {
const selected = selection.ranges.filter(hasEditorRangeSelection)
if (selected.length === 0)
return `<system-reminder>Note: The user opened the file "${selection.filePath}". This may or may not be relevant to the current task.</system-reminder>\n`
const ranges = selected.map((range, index) => {
const prefix = selected.length > 1 ? `Selection ${index + 1}: ` : ""
return `Note: The user selected ${prefix}${getEditorRangeLabel(range)} from "${selection.filePath}". \`\`\`${range.text}\`\`\`\n\n`
})
return `<system-reminder>${ranges.join("\n")} This may or may not be relevant to the current task.</system-reminder>\n`
}
let stashed: { prompt: PromptInfo; cursor: number } | undefined
export function Prompt(props: PromptProps) {
@@ -113,13 +141,21 @@ export function Prompt(props: PromptProps) {
const list = createMemo(() => props.placeholders?.normal ?? [])
const shell = createMemo(() => props.placeholders?.shell ?? [])
const fileContextEnabled = createMemo(() => kv.get("file_context_enabled", true))
const editorPath = createMemo(() => (fileContextEnabled() ? editor.selection()?.filePath : undefined))
const editorSelectionLabel = createMemo(() => {
const selection = fileContextEnabled() ? editor.selection()?.selection : undefined
const [dismissedEditorSelectionKey, setDismissedEditorSelectionKey] = createSignal<string>()
const editorContext = createMemo(() => {
const selection = fileContextEnabled() ? editor.selection() : undefined
if (!selection) return
if (selection.start.line === selection.end.line && selection.start.character === selection.end.character) return
if (selection.start.line === selection.end.line) return `#${selection.start.line}`
return `#${selection.start.line}-${selection.end.line}`
return editorSelectionKey(selection) === dismissedEditorSelectionKey() ? undefined : selection
})
const editorPath = createMemo(() => editorContext()?.filePath)
const editorSelectionLabel = createMemo(() => {
const ranges = editorContext()?.ranges
if (!ranges) return
const first = ranges.find(hasEditorRangeSelection) ?? ranges[0]
if (!first) return
return [getEditorRangeLabel(first), ranges.length > 1 ? `+${ranges.length - 1}` : undefined]
.filter(Boolean)
.join(" ")
})
const editorFileLabel = createMemo(() => {
const value = editorPath()
@@ -135,6 +171,8 @@ export function Prompt(props: PromptProps) {
if (!file) return
return Locale.truncateMiddle(file, Math.max(12, Math.min(48, Math.floor(dimensions().width / 3))))
})
const [editorContextHover, setEditorContextHover] = createSignal(false)
let lastSubmittedEditorSelectionKey: string | undefined
const [auto, setAuto] = createSignal<AutocompleteRef>()
const currentProviderLabel = createMemo(() => local.model.parsed().provider)
const hasRightContent = createMemo(() => Boolean(props.right))
@@ -150,6 +188,11 @@ export function Prompt(props: PromptProps) {
}
}
function dismissEditorContext() {
setDismissedEditorSelectionKey(editorSelectionKey(editorContext()))
editor.clearSelection()
}
const textareaKeybindings = useTextareaKeybindings()
const fileStyleId = syntax().getStyleId("extmark.file")!
@@ -236,6 +279,11 @@ export function Prompt(props: PromptProps) {
const sessionID = props.sessionID
const msg = lastUserMessage()
if (!sessionID || !msg) return
// kilocode_change start - skip compaction messages while syncing local agent/model
const parts = sync.data.part[msg.id]
if (!parts) return
if (!KiloSessionTuiSync.model({ role: msg.role, parts })) return
// kilocode_change end
const key = [sessionID, msg.id].join(":")
if (key === syncedKey) return
@@ -281,6 +329,16 @@ export function Prompt(props: PromptProps) {
dialog.clear()
},
},
{
title: "Remove editor context",
value: "prompt.editor_context.clear",
category: "Prompt",
enabled: Boolean(editorContext()),
onSelect: (dialog) => {
dismissEditorContext()
dialog.clear()
},
},
{
title: "Paste",
value: "prompt.paste",
@@ -516,17 +574,11 @@ export function Prompt(props: PromptProps) {
createEffect(() => {
if (!input || input.isDestroyed) return
const capture =
store.mode === "normal"
? auto()?.visible
? (["escape", "navigate", "submit", "tab"] as const)
: (["tab"] as const)
: undefined
input.traits = {
capture,
suspend: !!props.disabled || store.mode === "shell",
status: store.mode === "shell" ? "SHELL" : undefined,
}
input.traits = computePromptTraits({
mode: store.mode,
disabled: !!props.disabled,
autocompleteVisible: !!auto()?.visible,
})
})
function restoreExtmarksFromParts(parts: PromptInfo["parts"]) {
@@ -757,37 +809,25 @@ export function Prompt(props: PromptProps) {
// Capture mode before it gets reset
const currentMode = store.mode
const variant = local.model.variant.current()
const editorSelection = fileContextEnabled() ? editor.selection() : undefined
const editorParts = editorSelection
? [
{
id: PartID.ascending(),
type: "text" as const,
text: (() => {
const start = editorSelection.selection.start
const end = editorSelection.selection.end
let text = ""
if (start.line === end.line && start.character === end.character) {
text = `Note: The user opened the file "${editorSelection.filePath}".`
} else if (start.line === end.line) {
text = `Note: The user selected line ${start.line + 1} from "${editorSelection.filePath}". \`\`\`${editorSelection.text}\`\`\`\n\n`
} else {
text = `Note: The user selected lines ${start.line + 1} to ${end.line + 1} from "${editorSelection.filePath}". \`\`\`${editorSelection.text}\`\`\`\n\n`
}
return `<system-reminder>${text} This may or may not be relevant to the current task.</system-reminder>\n`
})(),
synthetic: true,
metadata: {
kind: "editor_context",
source: editorSelection.source ?? "editor",
filePath: editorSelection.filePath,
selection: editorSelection.selection,
const editorSelection = editorContext()
const currentEditorSelectionKey = editorSelectionKey(editorSelection)
const editorParts =
editorSelection && currentEditorSelectionKey !== lastSubmittedEditorSelectionKey
? [
{
id: PartID.ascending(),
type: "text" as const,
text: formatEditorContext(editorSelection),
synthetic: true,
metadata: {
kind: "editor_context",
source: editorSelection.source ?? "editor",
filePath: editorSelection.filePath,
ranges: editorSelection.ranges,
},
},
},
]
: []
]
: []
if (store.mode === "shell") {
void sdk.client.session.shell({
@@ -850,7 +890,7 @@ export function Prompt(props: PromptProps) {
],
})
.catch(() => {})
editor.clearSelection()
lastSubmittedEditorSelectionKey = currentEditorSelectionKey
}
toast.dismiss() // kilocode_change - dismiss persistent config warning on first submit
history.append({
@@ -1240,7 +1280,7 @@ export function Prompt(props: PromptProps) {
const lineCount = (pastedContent.match(/\n/g)?.length ?? 0) + 1
if (
(lineCount >= 5 || pastedContent.length > 800) && // kilocode_change #7252 delay paste summary
!sync.data.config.experimental?.disable_paste_summary
kv.get("paste_summary_enabled", !sync.data.config.experimental?.disable_paste_summary)
) {
pasteText(pastedContent, `[Pasted ~${lineCount} lines]`)
return
@@ -1435,7 +1475,18 @@ export function Prompt(props: PromptProps) {
</text>
</Show>
{/* kilocode_change end */}
<Show when={editorFileLabelDisplay()}>{(file) => <text fg={theme.secondary}>{file()}</text>}</Show>
<Show when={editorFileLabelDisplay()}>
{(file) => (
<text
fg={theme.secondary}
onMouseOver={() => setEditorContextHover(true)}
onMouseOut={() => setEditorContextHover(false)}
onMouseUp={dismissEditorContext}
>
{editorContextHover() ? `x ${file()}` : file()}
</text>
)}
</Show>
<Switch>
<Match when={store.mode === "normal"}>
<Switch>
@@ -0,0 +1,31 @@
import type { EditorTraits } from "@opentui/core"
export type PromptMode = "normal" | "shell"
export interface PromptTraitsInput {
mode: PromptMode
disabled: boolean
autocompleteVisible: boolean
}
/**
* Compute the textarea editor traits for the prompt.
*
* `traits.suspend` gates the textarea's keybinding actions (backspace,
* delete-word, arrow movement, undo/redo, etc.). Shell mode is an active
* editing mode — only `disabled` should suspend the textarea, otherwise
* users can type in shell mode but cannot delete or move the cursor.
*/
export function computePromptTraits(input: PromptTraitsInput): EditorTraits {
const capture =
input.mode === "normal"
? input.autocompleteVisible
? (["escape", "navigate", "submit", "tab"] as const)
: (["tab"] as const)
: undefined
return {
capture,
suspend: input.disabled,
status: input.mode === "shell" ? "SHELL" : undefined,
}
}
@@ -12,6 +12,9 @@ const ZedEditorRowSchema = z.object({
workspace_paths: z.string().nullable(),
timestamp: z.string(),
buffer_path: z.string().nullable(),
})
const ZedSelectionRowSchema = z.object({
selection_start: z.number().nullable(),
selection_end: z.number().nullable(),
})
@@ -20,8 +23,11 @@ const ZedEditorContentsSchema = z.object({
contents: z.string().nullable(),
})
const utf8 = new TextEncoder()
type ZedEditorRow = z.infer<typeof ZedEditorRowSchema>
type ZedActiveEditorRow = ZedEditorRow & { item_kind: "Editor"; editor_id: number }
type ZedSelectionRow = z.infer<typeof ZedSelectionRowSchema>
export type ZedSelectionResult =
| { type: "selection"; selection: EditorSelection }
@@ -34,7 +40,21 @@ export async function resolveZedSelection(dbPath: string, cwd = process.cwd()):
const row = active.row
if (!row.buffer_path) return { type: "empty" }
if (row.selection_start == null || row.selection_end == null) return { type: "unavailable" }
const selections = queryZedEditorSelections(dbPath, row)
if (selections.type !== "selections") return selections
const byteRanges = selections.selections
.flatMap((selection) => {
if (selection.selection_start == null || selection.selection_end == null) return []
return [
{
start: Math.min(selection.selection_start, selection.selection_end),
end: Math.max(selection.selection_start, selection.selection_end),
},
]
})
.sort((left, right) => left.start - right.start || left.end - right.end)
if (byteRanges.length === 0) return { type: "unavailable" }
const contents = queryZedEditorContents(dbPath, row)
const text =
@@ -45,16 +65,21 @@ export async function resolveZedSelection(dbPath: string, cwd = process.cwd()):
.catch(() => undefined)
if (text == null) return { type: "unavailable" }
const startOffset = Math.min(row.selection_start, row.selection_end)
const endOffset = Math.max(row.selection_start, row.selection_end)
const ranges = byteRanges.map((range) => {
const startOffset = utf8ByteOffsetToStringIndex(text, range.start)
const endOffset = utf8ByteOffsetToStringIndex(text, range.end)
return {
text: text.slice(startOffset, endOffset),
selection: offsetsToSelection(text, startOffset, endOffset),
}
})
return {
type: "selection",
selection: {
text: text.slice(startOffset, endOffset),
filePath: row.buffer_path,
source: "zed",
selection: offsetsToSelection(text, startOffset, endOffset),
ranges,
},
}
}
@@ -71,14 +96,11 @@ function queryZedActiveEditor(dbPath: string, cwd: string) {
i.workspace_id as workspace_id,
w.paths as workspace_paths,
w.timestamp as timestamp,
e.buffer_path as buffer_path,
s.start as selection_start,
s.end as selection_end
e.buffer_path as buffer_path
from items i
join panes p on p.pane_id = i.pane_id and p.workspace_id = i.workspace_id
join workspaces w on w.workspace_id = i.workspace_id
left join editors e on e.item_id = i.item_id and e.workspace_id = i.workspace_id
left join editor_selections s on s.editor_id = e.item_id and s.workspace_id = e.workspace_id
where i.active = 1 and p.active = 1
order by w.timestamp desc`,
)
@@ -106,6 +128,34 @@ function queryZedActiveEditor(dbPath: string, cwd: string) {
}
}
function queryZedEditorSelections(dbPath: string, row: ZedActiveEditorRow) {
let db: Database | undefined
try {
db = new Database(dbPath, { readonly: true })
const raw = db
.query(
`select
start as selection_start,
end as selection_end
from editor_selections
where editor_id = $editorID and workspace_id = $workspaceID`,
)
.all({ $editorID: row.editor_id, $workspaceID: row.workspace_id })
const selections = raw.flatMap((selection) => {
const parsed = ZedSelectionRowSchema.safeParse(selection)
return parsed.success ? [parsed.data] : []
})
if (raw.length > 0 && selections.length === 0) return { type: "unavailable" as const }
return { type: "selections" as const, selections }
} catch {
return { type: "unavailable" as const }
} finally {
db?.close()
}
}
function queryZedEditorContents(dbPath: string, row: ZedActiveEditorRow) {
let db: Database | undefined
try {
@@ -139,13 +189,20 @@ export function resolveZedDbPath() {
path.join(os.homedir(), ".local", "share", "zed", "db", "0-stable", "db.sqlite"),
].filter((item): item is string => Boolean(item))
return candidates.find((item) => Filesystem.stat(item)?.isFile())
return candidates.find((item) => isFile(item))
}
function isFile(item: string) {
try {
return Filesystem.stat(item)?.isFile() === true
} catch {
return false
}
}
function scoreZedWorkspace(workspacePaths: string | null, cwd: string) {
return zedWorkspacePaths(workspacePaths).reduce((score, item) => {
if (pathContains(item, cwd)) return Math.max(score, 2)
if (pathContains(cwd, item)) return Math.max(score, 1)
if (pathContains(item, cwd)) return Math.max(score, path.resolve(item).length)
return score
}, 0)
}
@@ -158,7 +215,25 @@ function zedWorkspacePaths(value: string | null) {
}
export function offsetToPosition(text: string, offset: number) {
return offsetsToSelection(text, offset, offset).start
const stringOffset = utf8ByteOffsetToStringIndex(text, offset)
return offsetsToSelection(text, stringOffset, stringOffset).start
}
function utf8ByteOffsetToStringIndex(text: string, byteOffset: number) {
if (byteOffset <= 0) return 0
let bytes = 0
for (let index = 0; index < text.length; ) {
const codePoint = text.codePointAt(index)
if (codePoint === undefined) return text.length
const nextIndex = index + (codePoint > 0xffff ? 2 : 1)
bytes += utf8.encode(text.slice(index, nextIndex)).length
if (bytes >= byteOffset) return nextIndex
index = nextIndex
}
return text.length
}
function offsetsToSelection(text: string, startOffset: number, endOffset: number) {
@@ -28,16 +28,46 @@ const PositionSchema = z.object({
character: z.number(),
})
const EditorSelectionSchema = z.object({
const EditorSelectionRangeSchema = z.object({
text: z.string(),
filePath: z.string(),
source: z.enum(["websocket", "zed"]).optional(),
selection: z.object({
start: PositionSchema,
end: PositionSchema,
}),
})
const EditorSelectionSchema = z
.union([
z.object({
filePath: z.string(),
source: z.enum(["websocket", "zed"]).optional(),
ranges: z.array(EditorSelectionRangeSchema).min(1),
}),
z.object({
text: z.string(),
filePath: z.string(),
source: z.enum(["websocket", "zed"]).optional(),
selection: z.object({
start: PositionSchema,
end: PositionSchema,
}),
}),
])
.transform((value) =>
"ranges" in value
? value
: {
filePath: value.filePath,
source: value.source,
ranges: [
{
text: value.text,
selection: value.selection,
},
],
},
)
const EditorMentionSchema = z.object({
filePath: z.string(),
lineStart: z.number(),
@@ -75,8 +105,9 @@ type EditorLockFile = {
export const { use: useEditorContext, provider: EditorContextProvider } = createSimpleContext({
name: "EditorContext",
init: () => {
init: (props: { WebSocketImpl?: typeof WebSocket }) => {
const mentionListeners = new Set<(mention: EditorMention) => void>()
const WebSocketImpl = props.WebSocketImpl ?? WebSocket
const [store, setStore] = createStore<{
status: "disabled" | "connecting" | "connected"
selection: EditorSelection | undefined
@@ -87,138 +118,160 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create
server: undefined,
})
onMount(() => {
let socket: WebSocket | undefined
let closed = false
let reconnect: ReturnType<typeof setTimeout> | undefined
let attempt = 0
let requestID = 0
let zedSelection: Promise<void> | undefined
let lastZedSelectionKey: string | undefined
const pending = new Map<number, string>()
let socket: WebSocket | undefined
let closed = false
let reconnect: ReturnType<typeof setTimeout> | undefined
let attempt = 0
let requestID = 0
let zedSelection: Promise<void> | undefined
let lastZedSelectionKey: string | undefined
let directory = process.cwd()
const pending = new Map<number, string>()
const send = (payload: JsonRpcMessage) => {
if (!socket || socket.readyState !== WebSocket.OPEN) return
socket.send(JSON.stringify({ jsonrpc: "2.0", ...payload }))
const send = (payload: JsonRpcMessage) => {
if (!socket || socket.readyState !== 1) return
socket.send(JSON.stringify({ jsonrpc: "2.0", ...payload }))
}
const request = (method: string, params?: unknown) => {
requestID += 1
pending.set(requestID, method)
send({ id: requestID, method, params })
}
const connect = () => {
if (closed) return
const connection = resolveEditorConnection(directory)
if (!connection) {
const dbPath = resolveZedDbPath()
if (!dbPath) {
setStore("status", "disabled")
scheduleReconnect()
return
}
zedSelection ??= resolveZedSelection(dbPath, directory)
.then((result) => {
if (closed || socket) return
if (result.type === "unavailable") return
const selection = result.type === "selection" ? result.selection : undefined
const key = editorSelectionKey(selection)
if (key !== lastZedSelectionKey) {
lastZedSelectionKey = key
setStore("selection", selection)
setStore("status", selection ? "connected" : "disabled")
}
})
.catch(() => {
// Keep the last known Zed selection for transient polling failures.
})
.finally(() => {
zedSelection = undefined
})
scheduleZedPoll()
return
}
const request = (method: string, params?: unknown) => {
requestID += 1
pending.set(requestID, method)
send({ id: requestID, method, params })
}
setStore("status", "connecting")
const current = openEditorSocket(connection, WebSocketImpl)
socket = current
const scheduleReconnect = () => {
if (closed) return
if (reconnect) clearTimeout(reconnect)
attempt += 1
const delay = Math.min(1000 * 2 ** (attempt - 1), 10_000)
reconnect = setTimeout(connect, delay)
}
const scheduleZedPoll = () => {
if (closed) return
if (reconnect) clearTimeout(reconnect)
reconnect = setTimeout(connect, 1000)
}
const connect = () => {
if (closed) return
const connection = resolveEditorConnection()
if (!connection) {
const dbPath = resolveZedDbPath()
if (!dbPath) {
setStore("status", "disabled")
scheduleReconnect()
return
}
zedSelection ??= resolveZedSelection(dbPath)
.then((result) => {
if (closed || socket) return
if (result.type === "unavailable") return
const selection = result.type === "selection" ? result.selection : undefined
const key = editorSelectionKey(selection)
if (key !== lastZedSelectionKey) {
lastZedSelectionKey = key
setStore("selection", selection)
setStore("status", selection ? "connected" : "disabled")
}
})
.catch(() => {
// Keep the last known Zed selection for transient polling failures.
})
.finally(() => {
zedSelection = undefined
})
scheduleZedPoll()
current.addEventListener("open", () => {
if (socket !== current) {
current.close()
return
}
attempt = 0
setStore("status", "connected")
request("initialize", {
protocolVersion: MCP_PROTOCOL_VERSION,
capabilities: {},
clientInfo: { name: "opencode", version: "0.0.0" },
})
})
current.addEventListener("message", (event) => {
const message = parseMessage(event.data)
if (!message) return
const selection =
message.method === "selection_changed" ? EditorSelectionSchema.safeParse(message.params) : undefined
if (selection?.success) {
setStore("selection", { ...selection.data, source: "websocket" })
return
}
const mention = message.method === "at_mentioned" ? EditorMentionSchema.safeParse(message.params) : undefined
if (mention?.success) {
mentionListeners.forEach((listener) => listener(mention.data))
return
}
if (typeof message.id !== "number") return
const method = pending.get(message.id)
if (!method) return
pending.delete(message.id)
if (message.error) return
const initialize = method === "initialize" ? EditorServerInfoSchema.safeParse(message.result) : undefined
if (initialize?.success) {
setStore("server", initialize.data)
send({ method: "notifications/initialized" })
return
}
})
current.addEventListener("close", () => {
if (socket !== current) return
socket = undefined
pending.clear()
if (closed) return
setStore("status", "connecting")
const current = openEditorSocket(connection)
socket = current
scheduleReconnect()
})
}
current.addEventListener("open", () => {
if (socket !== current) {
current.close()
return
}
const scheduleReconnect = () => {
if (closed) return
if (reconnect) clearTimeout(reconnect)
attempt += 1
const delay = Math.min(1000 * 2 ** (attempt - 1), 10_000)
reconnect = setTimeout(connect, delay)
}
attempt = 0
setStore("status", "connected")
request("initialize", {
protocolVersion: MCP_PROTOCOL_VERSION,
capabilities: {},
clientInfo: { name: "opencode", version: "0.0.0" },
})
})
const scheduleZedPoll = () => {
if (closed) return
if (reconnect) clearTimeout(reconnect)
reconnect = setTimeout(connect, 1000)
}
current.addEventListener("message", (event) => {
const message = parseMessage(event.data)
if (!message) return
const reconnectWithDirectory = (nextDirectory?: string) => {
const resolved = nextDirectory || process.cwd()
if (directory === resolved) return
const selection =
message.method === "selection_changed" ? EditorSelectionSchema.safeParse(message.params) : undefined
if (selection?.success) {
setStore("selection", { ...selection.data, source: "websocket" })
return
}
const mention = message.method === "at_mentioned" ? EditorMentionSchema.safeParse(message.params) : undefined
if (mention?.success) {
mentionListeners.forEach((listener) => listener(mention.data))
return
}
if (typeof message.id !== "number") return
const method = pending.get(message.id)
if (!method) return
pending.delete(message.id)
if (message.error) return
const initialize = method === "initialize" ? EditorServerInfoSchema.safeParse(message.result) : undefined
if (initialize?.success) {
setStore("server", initialize.data)
send({ method: "notifications/initialized" })
return
}
})
current.addEventListener("close", () => {
if (socket !== current) return
socket = undefined
pending.clear()
if (closed) return
setStore("status", "connecting")
scheduleReconnect()
})
directory = resolved
attempt = 0
pending.clear()
lastZedSelectionKey = undefined
if (reconnect) clearTimeout(reconnect)
reconnect = undefined
if (socket) {
const current = socket
socket = undefined
current.close()
}
setStore("status", "disabled")
setStore("selection", undefined)
setStore("server", undefined)
connect()
}
onMount(() => {
connect()
onCleanup(() => {
@@ -230,7 +283,7 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create
return {
enabled() {
return Boolean(resolveEditorConnection() || resolveZedDbPath())
return Boolean(resolveEditorConnection(directory) || resolveZedDbPath())
},
connected() {
return store.status === "connected"
@@ -239,6 +292,7 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create
return store.selection
},
clearSelection() {
lastZedSelectionKey = undefined
setStore("selection", undefined)
},
onMention(listener: (mention: EditorMention) => void) {
@@ -248,6 +302,10 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create
server() {
return store.server
},
reconnect(directory?: string) {
setStore("selection", undefined)
reconnectWithDirectory(directory)
},
}
},
})
@@ -260,8 +318,16 @@ function parsePort(value: string | undefined) {
return parsed
}
function resolveEditorConnection(): EditorConnection | undefined {
const lock = resolveEditorLockFile()
function resolveEditorConnection(directory: string): EditorConnection | undefined {
const port = parsePort(process.env.CLAUDE_CODE_SSE_PORT || process.env.KILO_EDITOR_SSE_PORT)
if (port) {
return {
url: `ws://127.0.0.1:${port}`,
source: `env:${port}`,
}
}
const lock = resolveEditorLockFile(directory)
if (lock) {
return {
url: `ws://127.0.0.1:${lock.port}`,
@@ -269,16 +335,9 @@ function resolveEditorConnection(): EditorConnection | undefined {
source: `lock:${lock.port}`,
}
}
const port = parsePort(process.env.CLAUDE_CODE_SSE_PORT || process.env.KILO_EDITOR_SSE_PORT)
if (!port) return
return {
url: `ws://127.0.0.1:${port}`,
source: `env:${port}`,
}
}
function resolveEditorLockFile() {
function resolveEditorLockFile(activeDirectory: string) {
const directory = path.join(os.homedir(), ".claude", "ide")
let entries: string[]
@@ -288,10 +347,9 @@ function resolveEditorLockFile() {
return
}
const cwd = process.cwd()
// longest workspace folder that contains cwd; 0 if none match
// longest workspace folder that contains the active session directory; 0 if none match
const bestMatchLength = (lock: EditorLockFile) =>
Math.max(0, ...lock.workspaceFolders.map((folder) => pathContainsLength(folder, cwd)))
Math.max(0, ...lock.workspaceFolders.map((folder) => pathContainsLength(folder, activeDirectory)))
const locks = entries
.filter((entry) => entry.endsWith(".lock"))
.map((entry) => readEditorLockFile(path.join(directory, entry)))
@@ -325,15 +383,17 @@ function readEditorLockFile(filePath: string): EditorLockFile | undefined {
}
}
function editorSelectionKey(selection: EditorSelection | undefined) {
export function editorSelectionKey(selection: EditorSelection | undefined) {
if (!selection) return ""
return [
selection.filePath,
selection.selection.start.line,
selection.selection.start.character,
selection.selection.end.line,
selection.selection.end.character,
selection.text,
...selection.ranges.flatMap((range) => [
range.selection.start.line,
range.selection.start.character,
range.selection.end.line,
range.selection.end.character,
range.text,
]),
].join("\0")
}
@@ -343,10 +403,10 @@ function pathContainsLength(parent: string, child: string) {
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)) ? resolved.length : 0
}
function openEditorSocket(connection: EditorConnection) {
if (!connection.authToken) return new WebSocket(connection.url)
function openEditorSocket(connection: EditorConnection, WebSocketImpl: typeof WebSocket) {
if (!connection.authToken) return new WebSocketImpl(connection.url)
return new WebSocket(connection.url, {
return new WebSocketImpl(connection.url, {
headers: {
"x-claude-code-ide-authorization": connection.authToken,
},
@@ -188,9 +188,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
favorite: modelStore.favorite,
variant: modelStore.variant,
}
state.writer = state.writer
.then(() => Filesystem.writeJson(filePath, data))
.catch(() => {})
state.writer = state.writer.then(() => Filesystem.writeJson(filePath, data)).catch(() => {})
// kilocode_change end
}
@@ -272,6 +270,15 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
return modelStore.model[name]
},
// kilocode_change end
// kilocode_change start - resolve once all queued writes (atomic write+rename) have settled.
// Used by tests to deterministically await the writer chain instead of sleeping for a fixed
// duration, which is too slow on Windows CI where temp-file rename can exceed 50ms under AV.
async flush() {
const deadline = Date.now() + 5000
while (state.pending && Date.now() < deadline) await new Promise((r) => setTimeout(r, 0))
await state.writer
},
// kilocode_change end
recent() {
return modelStore.recent
},
@@ -36,6 +36,8 @@ import * as Log from "@opencode-ai/core/util/log"
import { emptyConsoleState, type ConsoleState } from "@/config/console-state"
import type { IndexingStatus } from "@kilocode/kilo-indexing/status" // kilocode_change
import { KiloIndexing } from "@/kilocode/indexing" // kilocode_change
import path from "path"
import { useKV } from "./kv"
export const { use: useSync, provider: SyncProvider } = createSimpleContext({
name: "Sync",
@@ -128,6 +130,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const project = useProject()
const sdk = useSDK()
const toast = useToast() // kilocode_change
const kv = useKV()
// kilocode_change start
function evict(sessionID: string) {
@@ -164,6 +167,22 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const fullSyncedSessions = new Set<string>()
let syncedWorkspace = project.workspace.current()
function sessionListQuery(): { scope?: "project"; path?: string } {
if (!kv.get("session_directory_filter_enabled", true)) return { scope: "project" }
if (!project.data.instance.path.worktree || !project.data.instance.path.directory) return { scope: "project" }
return {
path: path
.relative(path.resolve(project.data.instance.path.worktree), project.data.instance.path.directory)
.replaceAll("\\", "/"),
}
}
function listSessions() {
return sdk.client.session
.list({ start: Date.now() - 30 * 24 * 60 * 60 * 1000, ...sessionListQuery() })
.then((x) => (x.data ?? []).toSorted((a, b) => a.id.localeCompare(b.id)))
}
event.subscribe((event) => {
switch (event.type) {
case "server.instance.disposed":
@@ -491,10 +510,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
fullSyncedSessions.clear()
syncedWorkspace = workspace
}
const start = Date.now() - 30 * 24 * 60 * 60 * 1000
const sessionListPromise = sdk.client.session
.list({ start: start })
.then((x) => (x.data ?? []).toSorted((a, b) => a.id.localeCompare(b.id)))
const projectPromise = project.sync()
const sessionListPromise = projectPromise.then(() => listSessions())
// blocking - include session.list when continuing a session
const providersPromise = sdk.client.config.providers({ workspace }, { throwOnError: true })
@@ -505,7 +522,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
.catch(() => emptyConsoleState)
const agentsPromise = sdk.client.app.agents({ workspace }, { throwOnError: true })
const configPromise = sdk.client.config.get({ workspace }, { throwOnError: true })
const projectPromise = project.sync()
const blockingRequests: Promise<unknown>[] = [
providersPromise,
providerListPromise,
@@ -652,11 +668,11 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
if (match.found) return store.session[match.index]
return undefined
},
query() {
return sessionListQuery()
},
async refresh() {
const start = Date.now() - 30 * 24 * 60 * 60 * 1000
const list = await sdk.client.session
.list({ start })
.then((x) => (x.data ?? []).toSorted((a, b) => a.id.localeCompare(b.id)))
const list = await listSessions()
setStore("session", reconcile(list))
},
status(sessionID: string) {
@@ -430,12 +430,16 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
// kilocode_change start - safe fallback to kilo import if store lookup fails
const values = createMemo(() => {
const active = store.themes[store.active]
if (active) return resolveTheme(active, store.mode)
if (active) {
return resolveTheme(active, store.mode)
}
const saved = kv.get("theme")
if (typeof saved === "string") {
const theme = store.themes[saved]
if (theme) return resolveTheme(theme, store.mode)
if (theme) {
return resolveTheme(theme, store.mode)
}
}
return resolveTheme(store.themes.kilo, store.mode) // kilocode_change
+2 -1
View File
@@ -1,5 +1,6 @@
import { BusEvent } from "@/bus/bus-event"
import { SessionID } from "@/session/schema"
import { PositiveInt } from "@/util/schema"
import { Effect, Schema } from "effect"
const DEFAULT_TOAST_DURATION = 5000
@@ -38,7 +39,7 @@ export const TuiEvent = {
title: Schema.optional(Schema.String),
message: Schema.String,
variant: Schema.Literals(["info", "success", "warning", "error"]),
duration: Schema.Number.pipe(Schema.withDecodingDefault(Effect.succeed(DEFAULT_TOAST_DURATION))).annotate({
duration: PositiveInt.pipe(Schema.withDecodingDefault(Effect.succeed(DEFAULT_TOAST_DURATION))).annotate({
description: "Duration in milliseconds",
}),
}),
@@ -38,7 +38,6 @@ import type { GrepTool } from "@/tool/grep"
import type { EditTool } from "@/tool/edit"
import type { ApplyPatchTool } from "@/tool/apply_patch"
import type { WebFetchTool } from "@/tool/webfetch"
import type { CodeSearchTool } from "@/tool/codesearch"
import type { WebSearchTool } from "@/tool/websearch"
import type { TaskTool } from "@/tool/task"
import type { QuestionTool } from "@/tool/question"
@@ -46,6 +45,7 @@ import type { SkillTool } from "@/tool/skill"
import type { SemanticSearchTool } from "@/kilocode/tool/semantic-search" // kilocode_change
import { useKeyboard, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
import { useSDK } from "@tui/context/sdk"
import { useEditorContext } from "@tui/context/editor"
import { useCommandDialog } from "@tui/component/dialog-command"
import type { DialogContext } from "@tui/ui/dialog"
import { useKeybind } from "@tui/context/keybind"
@@ -86,6 +86,7 @@ import { useTuiConfig } from "../../context/tui-config"
import { formatMarkdownTables } from "../../util/markdown" // kilocode_change
import { bell } from "@/kilocode/bell" // kilocode_change
import { SessionIndexing } from "@/kilocode/components/session-indexing" // kilocode_change
import { submitFeedback } from "@/kilocode/cli/cmd/tui/feedback" // kilocode_change
import { getScrollAcceleration } from "../../util/scroll"
import { TuiPluginRuntime } from "@/cli/cmd/tui/plugin/runtime"
import { DialogGoUpsell } from "../../component/dialog-go-upsell"
@@ -263,6 +264,7 @@ export function Session() {
const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig))
const toast = useToast()
const sdk = useSDK()
const editor = useEditorContext()
createEffect(() => {
const sessionID = route.sessionID
@@ -290,6 +292,7 @@ export function Session() {
await sync.bootstrap({ fatal: false })
} catch {}
}
editor.reconnect(result.data.directory)
await sync.session.sync(sessionID)
if (route.sessionID === sessionID && scroll) scroll.scrollBy(100_000)
})().catch((error) => {
@@ -947,6 +950,22 @@ export function Session() {
dialog.clear()
},
},
// kilocode_change start - message feedback
{
title: "Rate last assistant message helpful",
value: "messages.feedback.up",
keybind: "messages_feedback_up",
category: "Session",
onSelect: (dialog) => submitFeedback("up", dialog, { toast, session, messages }),
},
{
title: "Rate last assistant message not helpful",
value: "messages.feedback.down",
keybind: "messages_feedback_down",
category: "Session",
onSelect: (dialog) => submitFeedback("down", dialog, { toast, session, messages }),
},
// kilocode_change end
{
title: "Copy session transcript",
value: "session.copy",
@@ -1698,9 +1717,6 @@ function ToolPart(props: { last: boolean; part: ToolPart; message: AssistantMess
<Match when={props.part.tool === "webfetch"}>
<WebFetch {...toolprops} />
</Match>
<Match when={props.part.tool === "codesearch"}>
<CodeSearch {...toolprops} />
</Match>
<Match when={props.part.tool === "websearch"}>
<WebSearch {...toolprops} />
</Match>
@@ -2097,15 +2113,6 @@ function WebFetch(props: ToolProps<typeof WebFetchTool>) {
)
}
function CodeSearch(props: ToolProps<typeof CodeSearchTool>) {
const metadata = props.metadata as { results?: number }
return (
<InlineTool icon="◇" pending="Searching code..." complete={props.input.query} part={props.part}>
Exa Code Search "{props.input.query}" <Show when={metadata.results}>({metadata.results} results)</Show>
</InlineTool>
)
}
function WebSearch(props: ToolProps<typeof WebSearchTool>) {
const metadata = props.metadata as { numResults?: number }
return (
@@ -354,21 +354,6 @@ export function PermissionPrompt(props: { request: PermissionRequest }) {
}
}
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
+7 -4
View File
@@ -74,6 +74,12 @@ async function input(value?: string) {
return piped + "\n" + value
}
export function resolveThreadDirectory(project?: string, envPWD = process.env.PWD, cwd = process.cwd()) {
const root = Filesystem.resolve(envPWD ?? cwd)
if (project) return Filesystem.resolve(path.isAbsolute(project) ? project : path.join(root, project))
return Filesystem.resolve(cwd)
}
export const TuiThreadCommand = cmd({
command: "$0 [project]",
describe: "start kilo tui", // kilocode_change
@@ -143,10 +149,7 @@ export const TuiThreadCommand = cmd({
// Resolve relative --project paths from PWD, then use the real cwd after
// chdir so the thread and worker share the same directory key.
const root = Filesystem.resolve(process.env.PWD ?? process.cwd())
const next = args.project
? Filesystem.resolve(path.isAbsolute(args.project) ? args.project : path.join(root, args.project))
: Filesystem.resolve(process.cwd())
const next = resolveThreadDirectory(args.project)
const file = await target()
try {
process.chdir(next)
@@ -42,7 +42,7 @@ export interface DialogSelectOption<T = any> {
categoryView?: JSX.Element
disabled?: boolean
bg?: RGBA
gutter?: JSX.Element
gutter?: () => JSX.Element
margin?: JSX.Element
onSelect?: (ctx: DialogContext) => void
}
@@ -407,7 +407,7 @@ function Option(props: {
active?: boolean
current?: boolean
footer?: JSX.Element | string
gutter?: JSX.Element
gutter?: () => JSX.Element
onMouseOver?: () => void
}) {
const { theme } = useTheme()
@@ -422,7 +422,7 @@ function Option(props: {
</Show>
<Show when={!props.current && props.gutter}>
<box flexShrink={0} marginRight={0}>
{props.gutter}
{props.gutter?.()}
</box>
</Show>
<text
+4 -4
View File
@@ -2,7 +2,7 @@ import { Installation } from "@/installation"
import { Server } from "@/server/server"
import * as Log from "@opencode-ai/core/util/log"
import { Instance } from "@/project/instance"
import { InstanceBootstrap } from "@/project/bootstrap"
import { InstanceStore } from "@/project/instance-store"
import { Rpc } from "@/util/rpc"
import { upgrade } from "@/cli/upgrade"
import { Config } from "@/config/config"
@@ -10,7 +10,7 @@ import { GlobalBus } from "@/bus/global"
import { Flag } from "@opencode-ai/core/flag/flag"
import { writeHeapSnapshot } from "node:v8"
import { Heap } from "@/cli/heap"
import { AppRuntime } from "@/effect/app-runtime"
import { AppRuntime, getBootstrapRunEffect } from "@/effect/app-runtime"
import { ensureProcessMetadata } from "@opencode-ai/core/util/opencode-process"
ensureProcessMetadata("worker")
@@ -77,7 +77,7 @@ export const rpc = {
async checkUpgrade(input: { directory: string }) {
await Instance.provide({
directory: input.directory,
init: () => AppRuntime.runPromise(InstanceBootstrap),
init: await getBootstrapRunEffect(),
fn: async () => {
await upgrade().catch(() => {})
},
@@ -89,7 +89,7 @@ export const rpc = {
async shutdown() {
Log.Default.info("worker shutting down")
await Instance.disposeAll()
await InstanceStore.disposeAllInstances()
if (server) await server.stop(true)
// kilocode_change start - Clear the Rpc message channel so the worker's event loop can drain and
// exit naturally. Without this, the active onmessage handle keeps the
+2 -2
View File
@@ -3,7 +3,7 @@ import { UI } from "../ui"
import { cmd } from "./cmd"
import { withNetworkOptions, resolveNetworkOptions } from "../network"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Instance } from "../../project/instance" // kilocode_change
import { InstanceStore } from "../../project/instance-store" // kilocode_change
import open from "open"
import { networkInterfaces } from "os"
@@ -80,7 +80,7 @@ export const WebCommand = cmd({
const abort = new AbortController()
const shutdown = async () => {
try {
await Instance.disposeAll()
await InstanceStore.disposeAllInstances()
await server.stop(true)
} finally {
abort.abort()
+50
View File
@@ -0,0 +1,50 @@
import type { Argv } from "yargs"
import { Effect, Schema } from "effect"
import { AppRuntime, type AppServices } from "@/effect/app-runtime"
import { InstanceStore } from "@/project/instance-store"
import { cmd } from "./cmd/cmd"
/**
* User-visible command failure. Throw via `fail("...")` from an effectCmd handler
* to surface a printed message + non-zero exit. Recognised by the global error
* formatter in `src/cli/error.ts` (FormatError), so the existing top-level
* catch + cleanup in `src/index.ts` runs normally.
*/
export class CliError extends Schema.TaggedErrorClass<CliError>()("CliError", {
message: Schema.String,
exitCode: Schema.optional(Schema.Number),
}) {}
export const fail = (message: string, exitCode = 1) => Effect.fail(new CliError({ message, exitCode }))
/**
* Effect-native CLI command builder. Wraps yargs `cmd()` so the handler body is
* an `Effect` with `InstanceRef` provided and any `AppServices` yieldable.
*
* Errors propagate to the existing top-level handler in `src/index.ts`; use
* `fail("...")` for user-visible domain failures (clean exit, formatted message).
*
* Handlers are typically `Effect.fn("Cli.<name>")(function*(args) { ... })`,
* which adds a named tracing span per CLI invocation. Once all commands use
* `effectCmd`, swapping the underlying `cmd()` factory for effect/cli's
* `Command.make(...)` won't touch any handler bodies.
*/
export const effectCmd = <Args, A>(opts: {
command: string | readonly string[]
describe: string | false
builder?: (yargs: Argv) => Argv<Args>
/** Defaults to process.cwd(). Override for commands that take a directory positional. */
directory?: (args: Args) => string
handler: (args: Args) => Effect.Effect<A, CliError, AppServices | InstanceStore.Service>
}) =>
cmd<{}, Args>({
command: opts.command,
describe: opts.describe,
builder: opts.builder as never,
async handler(rawArgs) {
// yargs typing wraps Args in ArgumentsCamelCase<WithDoubleDash<...>>; cast at the boundary.
const args = rawArgs as unknown as Args
const directory = opts.directory?.(args) ?? process.cwd()
await AppRuntime.runPromise(InstanceStore.Service.use((s) => s.provide({ directory }, opts.handler(args))))
},
})
+7
View File
@@ -15,6 +15,13 @@ function isTaggedError(error: unknown, tag: string): boolean {
}
export function FormatError(input: unknown) {
// CliError: domain failure surfaced from an effectCmd handler via fail("...")
if (isTaggedError(input, "CliError")) {
const data = input as ErrorLike & { exitCode?: number }
if (data.exitCode != null) process.exitCode = data.exitCode
return data.message ?? ""
}
// MCPFailed: { name: string }
if (NamedError.hasName(input, "MCPFailed")) {
return `MCP server "${(input as ErrorLike).data?.name}" failed. Note, opencode does not support MCP authentication yet.`
+2 -2
View File
@@ -31,8 +31,8 @@ const AgentSchema = Schema.StructWithRest(
variant: Schema.optional(Schema.String).annotate({
description: "Default model variant for this agent (applies only when using the agent's configured model).",
}),
temperature: Schema.optional(Schema.NullOr(Schema.Number)), // kilocode_change - nullable for delete sentinel
top_p: Schema.optional(Schema.NullOr(Schema.Number)), // kilocode_change - nullable for delete sentinel
temperature: Schema.optional(Schema.NullOr(Schema.Finite)), // kilocode_change - nullable for delete sentinel
top_p: Schema.optional(Schema.NullOr(Schema.Finite)), // kilocode_change - nullable for delete sentinel
prompt: Schema.optional(Schema.NullOr(Schema.String)), // kilocode_change - nullable for delete sentinel
tools: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)).annotate({
description: "@deprecated Use 'permission' field instead",
+52 -29
View File
@@ -3,7 +3,7 @@ import path from "path"
import { pathToFileURL } from "url"
import os from "os"
import z from "zod"
import { mergeDeep, pipe } from "remeda"
import { mergeDeep } from "remeda"
import { Global } from "@opencode-ai/core/global"
import fsNode from "fs/promises"
import { NamedError } from "@opencode-ai/core/util/error"
@@ -11,7 +11,8 @@ import { Flag } from "@opencode-ai/core/flag/flag"
import { Auth } from "../auth"
import { Env } from "../env"
import { applyEdits, findNodeAtLocation, modify, parseTree } from "jsonc-parser" // kilocode_change - parseTree/findNodeAtLocation used in patchJsonc
import { Instance, type InstanceContext } from "../project/instance"
import { type InstanceContext } from "../project/instance"
import { InstanceStore } from "../project/instance-store"
import { InstallationLocal, InstallationVersion } from "@opencode-ai/core/installation/version"
import { existsSync } from "fs"
import { GlobalBus } from "@/bus/global"
@@ -23,7 +24,7 @@ import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { InstanceState } from "@/effect/instance-state"
import { Context, Duration, Effect, Exit, Fiber, Layer, Option, Schema } from "effect"
import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
import { InstanceRef } from "@/effect/instance-ref"
import { containsPath } from "../project/instance-context"
import { zod } from "@/util/effect-zod"
import { NonNegativeInt, PositiveInt, withStatics, type DeepMutable } from "@/util/schema"
import { ConfigAgent } from "./agent"
@@ -55,8 +56,13 @@ import { unique } from "remeda"
const log = Log.create({ service: "config" })
// Custom merge function that concatenates array fields instead of replacing them
// Keep remeda's deep conditional merge type out of hot config-loading paths; TS profiling showed it dominates here.
function mergeConfig(target: Info, source: Info): Info {
return mergeDeep(target, source) as Info
}
function mergeConfigConcatArrays(target: Info, source: Info): Info {
const merged = mergeDeep(target, source)
const merged = mergeConfig(target, source)
if (target.instructions && source.instructions) {
merged.instructions = Array.from(new Set([...target.instructions, ...source.instructions]))
}
@@ -83,7 +89,7 @@ export const Warning = z.object({
})
export type Warning = z.infer<typeof Warning>
const { toWarning, caught: caughtWarning, handleInvalid } = KilocodeConfig
const { caught: caughtWarning } = KilocodeConfig
// kilocode_change end
async function resolveLoadedPlugins<T extends { plugin?: ConfigPlugin.Spec[] }>(config: T, filepath: string) {
@@ -225,7 +231,8 @@ export const Info = Schema.Struct({
[Schema.Record(Schema.String, ConfigAgent.Info)],
),
).annotate({ description: "Agent configuration, see https://opencode.ai/docs/agents" }),
provider: Schema.optional(Schema.Record(Schema.String, ConfigProvider.Info)).annotate({
provider: Schema.optional(Schema.Record(Schema.String, Schema.NullOr(ConfigProvider.Info))).annotate({
// kilocode_change - nullable for delete sentinel
description: "Custom provider configurations and model overrides",
}),
mcp: Schema.optional(
@@ -474,16 +481,14 @@ export const layer = Layer.effect(
const loadGlobal = Effect.fnUntraced(function* () {
yield* Effect.promise(() => KilocodeConfig.migrateBashPermission()) // kilocode_change
let result: Info = pipe(
{},
mergeDeep(yield* loadFile(path.join(Global.Path.config, "config.json"))),
// kilocode_change start
mergeDeep(yield* loadFile(path.join(Global.Path.config, "kilo.json"))),
mergeDeep(yield* loadFile(path.join(Global.Path.config, "kilo.jsonc"))),
// kilocode_change end
mergeDeep(yield* loadFile(path.join(Global.Path.config, "opencode.json"))),
mergeDeep(yield* loadFile(path.join(Global.Path.config, "opencode.jsonc"))),
)
let result: Info = {}
result = mergeConfig(result, yield* loadFile(path.join(Global.Path.config, "config.json")))
// kilocode_change start
result = mergeConfig(result, yield* loadFile(path.join(Global.Path.config, "kilo.json")))
result = mergeConfig(result, yield* loadFile(path.join(Global.Path.config, "kilo.jsonc")))
// kilocode_change end
result = mergeConfig(result, yield* loadFile(path.join(Global.Path.config, "opencode.json")))
result = mergeConfig(result, yield* loadFile(path.join(Global.Path.config, "opencode.jsonc")))
const legacy = path.join(Global.Path.config, "config")
if (existsSync(legacy)) {
@@ -493,7 +498,7 @@ export const layer = Layer.effect(
const { provider, model, ...rest } = mod.default
if (provider && model) result.model = `${provider}/${model}`
result["$schema"] = "https://app.kilo.ai/config.json" // kilocode_change
result = mergeDeep(result, rest)
result = mergeConfig(result, rest)
await fsNode.writeFile(path.join(Global.Path.config, "config.json"), JSON.stringify(result, null, 2))
await fsNode.unlink(legacy)
})
@@ -575,7 +580,7 @@ export const layer = Layer.effect(
const pluginScopeForSource = Effect.fnUntraced(function* (source: string) {
if (source.startsWith("http://") || source.startsWith("https://")) return "global"
if (source === "KILO_CONFIG_CONTENT") return "local"
if (yield* InstanceRef.use((ctx) => Effect.succeed(Instance.containsPath(source, ctx)))) return "local"
if (containsPath(source, ctx)) return "local"
return "global"
})
@@ -600,10 +605,14 @@ export const layer = Layer.effect(
result.plugin_origins = plugins
})
const merge = (source: string, next: Info, kind?: ConfigPlugin.Scope) => {
result = mergeConfigConcatArrays(result, next)
return mergePluginOrigins(source, next.plugin, kind)
}
// kilocode_change start
const merge = Effect.fnUntraced(function* (source: string, next: Info, kind?: ConfigPlugin.Scope) {
const scope = kind ?? (yield* pluginScopeForSource(source))
const scoped = KilocodeConfig.scopeIndexing(next, scope)
result = mergeConfigConcatArrays(result, scoped)
return yield* mergePluginOrigins(source, scoped.plugin, scope)
})
// kilocode_change end
for (const [key, value] of Object.entries(auth)) {
if (value.type === "wellknown") {
@@ -836,16 +845,19 @@ export const layer = Layer.effect(
// kilocode_change end
// macOS managed preferences (.mobileconfig deployed via MDM) override everything
// kilocode_change start
const managed = yield* Effect.promise(() => ConfigManaged.readManagedPreferences())
if (managed) {
result = mergeConfigConcatArrays(
result,
yield* merge(
managed.source,
yield* loadConfig(managed.text, {
dir: path.dirname(managed.source),
source: managed.source,
}),
"global",
)
}
// kilocode_change end
for (const [name, mode] of Object.entries(result.mode ?? {})) {
result.agent = mergeDeep(result.agent ?? {}, {
@@ -948,12 +960,18 @@ export const layer = Layer.effect(
writable,
})
// kilocode_change end
if (options?.dispose !== false) yield* Effect.promise(() => Instance.dispose())
if (options?.dispose !== false) {
// Fail loudly if no instance is bound — silently skipping would
// mask "config update without an active instance" bugs. The throw
// comes from `Instance.current` inside `InstanceState.context`.
const ctx = yield* InstanceState.context
yield* Effect.promise(() => InstanceStore.disposeInstance(ctx))
}
})
const invalidate = Effect.fn("Config.invalidate")(function* (wait?: boolean) {
yield* invalidateGlobal
const task = Instance.disposeAll()
const task = InstanceStore.disposeAllInstances()
.catch(() => undefined)
.finally(() =>
GlobalBus.emit("event", {
@@ -977,16 +995,20 @@ export const layer = Layer.effect(
const patch = writableGlobal(config)
let next: Info
let changed: boolean
if (!file.endsWith(".jsonc")) {
const existing = ConfigParse.effectSchema(Info, ConfigParse.jsonc(before, file), file)
// kilocode_change - use `patch` (writableGlobal) so empty-string sentinels are stripped via undefined
const merged = KilocodeConfig.mergeConfig(writable(existing), patch)
yield* fs.writeFileString(file, JSON.stringify(merged, null, 2)).pipe(Effect.orDie)
const serialized = JSON.stringify(merged, null, 2)
changed = serialized !== before
if (changed) yield* fs.writeFileString(file, serialized).pipe(Effect.orDie)
next = merged
} else {
const updated = patchJsonc(before, patch)
next = ConfigParse.effectSchema(Info, ConfigParse.jsonc(updated, file), file)
yield* fs.writeFileString(file, updated).pipe(Effect.orDie)
changed = updated !== before
if (changed) yield* fs.writeFileString(file, updated).pipe(Effect.orDie)
}
// kilocode_change start - skip dispose when caller opts out
@@ -1006,7 +1028,8 @@ export const layer = Layer.effect(
}
// kilocode_change end
yield* invalidate()
// Only tear down running instances if the config actually changed.
if (changed) yield* invalidate()
return next
})
@@ -1,10 +1,11 @@
import { Schema } from "effect"
import { zod } from "@/util/effect-zod"
import { NonNegativeInt } from "@/util/schema"
export class ConsoleState extends Schema.Class<ConsoleState>("ConsoleState")({
consoleManagedProviders: Schema.mutable(Schema.Array(Schema.String)),
activeOrgName: Schema.optional(Schema.String),
switchableOrgCount: Schema.Number,
switchableOrgCount: NonNegativeInt,
}) {
static readonly zod = zod(this)
}
+4
View File
@@ -51,6 +51,10 @@ const KeybindsSchema = Schema.Struct({
messages_copy: keybind("<leader>y", "Copy message"),
messages_undo: keybind("<leader>u", "Undo message"),
messages_redo: keybind("<leader>r", "Redo message"),
// kilocode_change start - message feedback
messages_feedback_up: keybind("<leader>=", "Rate last assistant message helpful"),
messages_feedback_down: keybind("<leader>-", "Rate last assistant message not helpful"),
// kilocode_change end
messages_toggle_conceal: keybind("<leader>h", "Toggle code block concealment in messages"),
tool_details: keybind("none", "Toggle tool details visibility"),
model_list: keybind("<leader>m", "List available models"),
+3 -3
View File
@@ -1,6 +1,6 @@
import { Schema } from "effect"
import { zod } from "@/util/effect-zod"
import { withStatics } from "@/util/schema"
import { PositiveInt, withStatics } from "@/util/schema"
export const Local = Schema.Struct({
type: Schema.Literal("local").annotate({ description: "Type of MCP server connection" }),
@@ -13,7 +13,7 @@ export const Local = Schema.Struct({
enabled: Schema.optional(Schema.Boolean).annotate({
description: "Enable or disable the MCP server on startup",
}),
timeout: Schema.optional(Schema.Number).annotate({
timeout: Schema.optional(PositiveInt).annotate({
description: "Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified.",
}),
})
@@ -49,7 +49,7 @@ export const Remote = Schema.Struct({
oauth: Schema.optional(Schema.Union([OAuth, Schema.Literal(false)])).annotate({
description: "OAuth authentication configuration for the MCP server. Set to false to disable OAuth auto-detection.",
}),
timeout: Schema.optional(Schema.Number).annotate({
timeout: Schema.optional(PositiveInt).annotate({
description: "Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified.",
}),
})
@@ -35,7 +35,6 @@ const InputObject = Schema.StructWithRest(
question: Schema.optional(Action),
webfetch: Schema.optional(Action),
websearch: Schema.optional(Action),
codesearch: Schema.optional(Action),
lsp: Schema.optional(Rule),
doom_loop: Schema.optional(Action),
skill: Schema.optional(Rule),
+11 -11
View File
@@ -23,25 +23,25 @@ export const Model = Schema.Struct({
),
cost: Schema.optional(
Schema.Struct({
input: Schema.Number,
output: Schema.Number,
cache_read: Schema.optional(Schema.Number),
cache_write: Schema.optional(Schema.Number),
input: Schema.Finite,
output: Schema.Finite,
cache_read: Schema.optional(Schema.Finite),
cache_write: Schema.optional(Schema.Finite),
context_over_200k: Schema.optional(
Schema.Struct({
input: Schema.Number,
output: Schema.Number,
cache_read: Schema.optional(Schema.Number),
cache_write: Schema.optional(Schema.Number),
input: Schema.Finite,
output: Schema.Finite,
cache_read: Schema.optional(Schema.Finite),
cache_write: Schema.optional(Schema.Finite),
}),
),
}),
),
limit: Schema.optional(
Schema.Struct({
context: Schema.Number,
input: Schema.optional(Schema.Number),
output: Schema.Number,
context: Schema.Finite,
input: Schema.optional(Schema.Finite),
output: Schema.Finite,
}),
),
modalities: Schema.optional(
@@ -0,0 +1,45 @@
import type { ProjectID } from "@/project/schema"
import type { WorkspaceAdapter, WorkspaceAdapterEntry } from "../types"
import { WorktreeAdapter } from "./worktree"
const BUILTIN: Record<string, WorkspaceAdapter> = {
worktree: WorktreeAdapter,
}
const state = new Map<ProjectID, Map<string, WorkspaceAdapter>>()
export function getAdapter(projectID: ProjectID, type: string): WorkspaceAdapter {
const custom = state.get(projectID)?.get(type)
if (custom) return custom
const builtin = BUILTIN[type]
if (builtin) return builtin
throw new Error(`Unknown workspace adapter: ${type}`)
}
export async function listAdapters(projectID: ProjectID): Promise<WorkspaceAdapterEntry[]> {
const builtin = await Promise.all(
Object.entries(BUILTIN).map(async ([type, adapter]) => {
return {
type,
name: adapter.name,
description: adapter.description,
}
}),
)
const custom = [...(state.get(projectID)?.entries() ?? [])].map(([type, adapter]) => ({
type,
name: adapter.name,
description: adapter.description,
}))
return [...builtin, ...custom]
}
// Plugins can be loaded per-project so we need to scope them. If you
// want to install a global one pass `ProjectID.global`
export function registerAdapter(projectID: ProjectID, type: string, adapter: WorkspaceAdapter) {
const adapters = state.get(projectID) ?? new Map<string, WorkspaceAdapter>()
adapters.set(type, adapter)
state.set(projectID, adapters)
}
@@ -1,7 +1,5 @@
import { Schema } from "effect"
import { AppRuntime } from "@/effect/app-runtime"
import { Worktree } from "@/worktree"
import { type WorkspaceAdaptor, WorkspaceInfo } from "../types"
import { type WorkspaceAdapter, WorkspaceInfo } from "../types"
const WorktreeConfig = Schema.Struct({
name: WorkspaceInfo.fields.name,
@@ -10,19 +8,26 @@ const WorktreeConfig = Schema.Struct({
})
const decodeWorktreeConfig = Schema.decodeUnknownSync(WorktreeConfig)
export const WorktreeAdaptor: WorkspaceAdaptor = {
async function loadWorktree() {
const [{ AppRuntime }, { Worktree }] = await Promise.all([import("@/effect/app-runtime"), import("@/worktree")])
return { AppRuntime, Worktree }
}
export const WorktreeAdapter: WorkspaceAdapter = {
name: "Worktree",
description: "Create a git worktree",
async configure(info) {
const worktree = await AppRuntime.runPromise(Worktree.Service.use((svc) => svc.makeWorktreeInfo()))
const { AppRuntime, Worktree } = await loadWorktree()
const next = await AppRuntime.runPromise(Worktree.Service.use((svc) => svc.makeWorktreeInfo()))
return {
...info,
name: worktree.name,
branch: worktree.branch,
directory: worktree.directory,
name: next.name,
branch: next.branch,
directory: next.directory,
}
},
async create(info) {
const { AppRuntime, Worktree } = await loadWorktree()
const config = decodeWorktreeConfig(info)
await AppRuntime.runPromise(
Worktree.Service.use((svc) =>
@@ -35,6 +40,7 @@ export const WorktreeAdaptor: WorkspaceAdaptor = {
)
},
async remove(info) {
const { AppRuntime, Worktree } = await loadWorktree()
const config = decodeWorktreeConfig(info)
await AppRuntime.runPromise(Worktree.Service.use((svc) => svc.remove({ directory: config.directory })))
},
@@ -1,46 +0,0 @@
import { lazy } from "@/util/lazy"
import type { ProjectID } from "@/project/schema"
import type { WorkspaceAdaptor, WorkspaceAdaptorEntry } from "../types"
const BUILTIN: Record<string, () => Promise<WorkspaceAdaptor>> = {
worktree: lazy(async () => (await import("./worktree")).WorktreeAdaptor),
}
const state = new Map<ProjectID, Map<string, WorkspaceAdaptor>>()
export async function getAdaptor(projectID: ProjectID, type: string): Promise<WorkspaceAdaptor> {
const custom = state.get(projectID)?.get(type)
if (custom) return custom
const builtin = BUILTIN[type]
if (builtin) return builtin()
throw new Error(`Unknown workspace adaptor: ${type}`)
}
export async function listAdaptors(projectID: ProjectID): Promise<WorkspaceAdaptorEntry[]> {
const builtin = await Promise.all(
Object.entries(BUILTIN).map(async ([type, init]) => {
const adaptor = await init()
return {
type,
name: adaptor.name,
description: adaptor.description,
}
}),
)
const custom = [...(state.get(projectID)?.entries() ?? [])].map(([type, adaptor]) => ({
type,
name: adaptor.name,
description: adaptor.description,
}))
return [...builtin, ...custom]
}
// Plugins can be loaded per-project so we need to scope them. If you
// want to install a global one pass `ProjectID.global`
export function registerAdaptor(projectID: ProjectID, type: string, adaptor: WorkspaceAdaptor) {
const adaptors = state.get(projectID) ?? new Map<string, WorkspaceAdaptor>()
adaptors.set(type, adaptor)
state.set(projectID, adaptors)
}
@@ -0,0 +1,19 @@
This is a plugin to simulate a remote environment locally. Add this to `.opencode/opencode.jsonc`:
```json
"plugin": ["../packages/opencode/src/control-plane/dev/debug-workspace-plugin.ts"],
```
In a separate terminal, run a separate OpenCode server. This will act like a remote server and the local instance will proxy all requests to it:
```
./packages/opencode/script/run-workspace-server
```
With the plugin install, you can now run OpenCode and create a `debug` workspace type. This will create a "remote" workspace which talks to the second workspace server started above.
How this works:
- The workspace server needs to know the workspace id and port to run. It waits for this information to be written to a file and starts the server when the data is written.
- The debug plugin writes this information in the `create` call to the workspace. So create a `debug` workspace will always kick off a new external server.
- The server script watches for file changes, so whenver you create a new `debug` workspace it will restart with the new information. This means that there is only ever one working `debug` workspace at a time; when you create a new one all previous sessions will show that it can't connect because previous debug workspaces do not exist.
@@ -1,66 +0,0 @@
export async function parseSSE(
body: ReadableStream<Uint8Array>,
signal: AbortSignal,
onEvent: (event: unknown) => void,
) {
const reader = body.getReader()
const decoder = new TextDecoder()
let buf = ""
let last = ""
let retry = 1000
const abort = () => {
void reader.cancel().catch(() => undefined)
}
signal.addEventListener("abort", abort)
try {
while (!signal.aborted) {
const chunk = await reader.read().catch(() => ({ done: true, value: undefined as Uint8Array | undefined }))
if (chunk.done) break
buf += decoder.decode(chunk.value, { stream: true })
buf = buf.replace(/\r\n/g, "\n").replace(/\r/g, "\n")
const chunks = buf.split("\n\n")
buf = chunks.pop() ?? ""
chunks.forEach((chunk) => {
const data: string[] = []
chunk.split("\n").forEach((line) => {
if (line.startsWith("data:")) {
data.push(line.replace(/^data:\s*/, ""))
return
}
if (line.startsWith("id:")) {
last = line.replace(/^id:\s*/, "")
return
}
if (line.startsWith("retry:")) {
const parsed = Number.parseInt(line.replace(/^retry:\s*/, ""), 10)
if (!Number.isNaN(parsed)) retry = parsed
}
})
if (!data.length) return
const raw = data.join("\n")
try {
onEvent(JSON.parse(raw))
} catch {
onEvent({
type: "sse.message",
properties: {
data: raw,
id: last || undefined,
retry,
},
})
}
})
}
} finally {
signal.removeEventListener("abort", abort)
reader.releaseLock()
}
}
+3 -3
View File
@@ -17,12 +17,12 @@ export const WorkspaceInfo = Schema.Struct({
.pipe(withStatics((s) => ({ zod: zod(s) })))
export type WorkspaceInfo = DeepMutable<Schema.Schema.Type<typeof WorkspaceInfo>>
export const WorkspaceAdaptorEntry = Schema.Struct({
export const WorkspaceAdapterEntry = Schema.Struct({
type: Schema.String,
name: Schema.String,
description: Schema.String,
}).pipe(withStatics((s) => ({ zod: zod(s) })))
export type WorkspaceAdaptorEntry = Schema.Schema.Type<typeof WorkspaceAdaptorEntry>
export type WorkspaceAdapterEntry = Schema.Schema.Type<typeof WorkspaceAdapterEntry>
export type Target =
| {
@@ -35,7 +35,7 @@ export type Target =
headers?: HeadersInit
}
export type WorkspaceAdaptor = {
export type WorkspaceAdapter = {
name: string
description: string
configure(info: WorkspaceInfo): WorkspaceInfo | Promise<WorkspaceInfo>
+8 -6
View File
@@ -1,22 +1,23 @@
import { GlobalBus, type GlobalEvent } from "@/bus/global"
import { Effect } from "effect"
export function waitEvent(input: { timeout: number; signal?: AbortSignal; fn: (event: GlobalEvent) => boolean }) {
if (input.signal?.aborted) return Promise.reject(input.signal.reason ?? new Error("Request aborted"))
if (input.signal?.aborted) return Effect.fail(input.signal.reason ?? new Error("Request aborted"))
return new Promise<void>((resolve, reject) => {
return Effect.callback<void, unknown>((resume) => {
const abort = () => {
cleanup()
reject(input.signal?.reason ?? new Error("Request aborted"))
resume(Effect.fail(input.signal?.reason ?? new Error("Request aborted")))
}
const handler = (event: GlobalEvent) => {
try {
if (!input.fn(event)) return
cleanup()
resolve()
resume(Effect.void)
} catch (error) {
cleanup()
reject(error)
resume(Effect.fail(error))
}
}
@@ -28,10 +29,11 @@ export function waitEvent(input: { timeout: number; signal?: AbortSignal; fn: (e
const timeout = setTimeout(() => {
cleanup()
reject(new Error("Timed out waiting for global event"))
resume(Effect.fail(new Error("Timed out waiting for global event")))
}, input.timeout)
GlobalBus.on("event", handler)
input.signal?.addEventListener("abort", abort, { once: true })
return Effect.sync(cleanup)
})
}
File diff suppressed because it is too large Load Diff
+26 -1
View File
@@ -1,4 +1,4 @@
import { Layer, ManagedRuntime } from "effect"
import { Effect, Layer, ManagedRuntime } from "effect"
import { attach } from "./run-service"
import * as Observability from "@opencode-ai/core/effect/observability"
@@ -14,6 +14,7 @@ import { FileWatcher } from "@/file/watcher"
import { Storage } from "@/storage/storage"
import { Snapshot } from "@/snapshot"
import { Plugin } from "@/plugin"
import { ModelsDev } from "@/provider/models"
import { Provider } from "@/provider/provider"
import { ProviderAuth } from "@/provider/auth"
import { Agent } from "@/agent/agent"
@@ -39,13 +40,17 @@ import { Command } from "@/command"
import { Truncate } from "@/tool/truncate"
import { ToolRegistry } from "@/tool/registry"
import { Format } from "@/format"
import { InstanceBootstrap } from "@/project/bootstrap"
import { InstanceStore } from "@/project/instance-store"
import { Project } from "@/project/project"
import { Vcs } from "@/project/vcs"
import { Workspace } from "@/control-plane/workspace"
import { Worktree } from "@/worktree"
import { Pty } from "@/pty"
import { Installation } from "@/installation"
import { ShareNext } from "@/share/share-next"
import { SessionShare } from "@/share/session"
import { SyncEvent } from "@/sync"
import { Npm } from "@opencode-ai/core/npm"
import { memoMap } from "@opencode-ai/core/effect/memo-map"
@@ -63,6 +68,7 @@ export const AppLayer = Layer.mergeAll(
Storage.defaultLayer,
Snapshot.defaultLayer,
Plugin.defaultLayer,
ModelsDev.defaultLayer,
Provider.defaultLayer,
ProviderAuth.defaultLayer,
Agent.defaultLayer,
@@ -88,17 +94,24 @@ export const AppLayer = Layer.mergeAll(
Truncate.defaultLayer,
ToolRegistry.defaultLayer,
Format.defaultLayer,
InstanceBootstrap.defaultLayer,
InstanceStore.defaultLayer,
Project.defaultLayer,
Vcs.defaultLayer,
Workspace.defaultLayer,
Worktree.defaultLayer,
Pty.defaultLayer,
Installation.defaultLayer,
ShareNext.defaultLayer,
SessionShare.defaultLayer,
SyncEvent.defaultLayer,
).pipe(Layer.provideMerge(Observability.layer))
const rt = ManagedRuntime.make(AppLayer, { memoMap })
type Runtime = Pick<typeof rt, "runSync" | "runPromise" | "runPromiseExit" | "runFork" | "runCallback" | "dispose">
/** Services provided by AppRuntime — i.e. what an Effect run via AppRuntime.runPromise can yield. */
export type AppServices = ManagedRuntime.ManagedRuntime.Services<typeof rt>
const wrap = (effect: Parameters<typeof rt.runSync>[0]) => attach(effect as never) as never
export const AppRuntime: Runtime = {
@@ -119,3 +132,15 @@ export const AppRuntime: Runtime = {
},
dispose: () => rt.dispose(),
}
let bootstrapRun: Promise<Effect.Effect<void>>
export function getBootstrapRunEffect(): Promise<Effect.Effect<void>> {
if (!bootstrapRun) {
bootstrapRun = AppRuntime.runPromise(
Effect.gen(function* () {
return (yield* InstanceBootstrap.Service).run
}),
)
}
return bootstrapRun
}
+29 -1
View File
@@ -1,4 +1,4 @@
import { Effect, Fiber } from "effect"
import { Effect, Exit, Fiber } from "effect"
import { WorkspaceContext } from "@/control-plane/workspace-context"
import { Instance, type InstanceContext } from "@/project/instance"
import type { WorkspaceID } from "@/control-plane/schema"
@@ -9,6 +9,7 @@ import { attachWith } from "./run-service"
export interface Shape {
readonly promise: <A, E, R>(effect: Effect.Effect<A, E, R>) => Promise<A>
readonly fork: <A, E, R>(effect: Effect.Effect<A, E, R>) => Fiber.Fiber<A, E>
readonly run: <A, E, R>(effect: Effect.Effect<A, E, R>) => Effect.Effect<A, E>
}
function restore<R>(instance: InstanceContext | undefined, workspace: WorkspaceID | undefined, fn: () => R): R {
@@ -20,6 +21,25 @@ function restore<R>(instance: InstanceContext | undefined, workspace: WorkspaceI
return fn()
}
/**
* Bridge from Effect into a Promise-returning JS callback while installing
* legacy `Instance.context` and `WorkspaceContext` AsyncLocalStorage for
* the duration of the callback. Effect's `InstanceRef`/`WorkspaceRef` do
* not propagate across async/await boundaries inside `Effect.promise(() =>
* async fn)` callbacks that re-enter Effect via `AppRuntime.runPromise`,
* but Node's AsyncLocalStorage does. Use this whenever an Effect crosses
* into JS that may itself spawn new Effect runtimes (workspace adapters,
* legacy plugins, etc.).
*
* Mirrors `Effect.promise` but restores legacy ALS first.
*/
export const fromPromise = <T>(fn: () => Promise<T> | T): Effect.Effect<T> =>
Effect.gen(function* () {
const instance = yield* InstanceRef
const workspace = yield* WorkspaceRef
return yield* Effect.promise(() => Promise.resolve(restore(instance, workspace, () => fn())))
})
export function make(): Effect.Effect<Shape> {
return Effect.gen(function* () {
const ctx = yield* Effect.context()
@@ -43,6 +63,14 @@ export function make(): Effect.Effect<Shape> {
restore(instance, workspace, () => Effect.runPromise(wrap(effect))),
fork: <A, E, R>(effect: Effect.Effect<A, E, R>) =>
restore(instance, workspace, () => Effect.runFork(wrap(effect))),
run: <A, E, R>(effect: Effect.Effect<A, E, R>) =>
Effect.callback<A, E>((resume) => {
restore(instance, workspace, () =>
Effect.runPromiseExit(wrap(effect)).then((exit) =>
resume(Exit.isSuccess(exit) ? Effect.succeed(exit.value) : Effect.failCause(exit.cause)),
),
)
}),
} satisfies Shape
})
}
@@ -0,0 +1,67 @@
import { Config, Context, Effect, Layer } from "effect"
type ConfigMap = Record<string, Config.Config<unknown>>
/**
* The service shape inferred from an object of Effect `Config` definitions.
*/
export type Shape<Fields extends ConfigMap> = {
readonly [Key in keyof Fields]: Config.Success<Fields[Key]>
}
/**
* A Context service class with generated layers for config-backed services.
*/
export type ServiceClass<Self, Id extends string, Service> = Context.ServiceClass<Self, Id, Service> & {
/** Provide already-parsed config, useful in tests. */
readonly layer: (input: Service) => Layer.Layer<Self>
/** Parse config once from the active Effect ConfigProvider and provide the service. */
readonly defaultLayer: Layer.Layer<Self, Config.ConfigError>
}
/**
* Create a Context service whose implementation is derived from Effect `Config`.
*
* This keeps Effect `Config` as the source of truth for env names, defaults, and
* validation while generating a typed service plus convenient production/test
* layers.
*
* ```ts
* class ServerAuthConfig extends ConfigService.Service<ServerAuthConfig>()(
* "@opencode/ServerAuthConfig",
* {
* password: Config.string("KILO_SERVER_PASSWORD").pipe(Config.option),
* username: Config.string("KILO_SERVER_USERNAME").pipe(Config.withDefault("opencode")),
* },
* ) {}
*
* const live = ServerAuthConfig.defaultLayer
* const test = ServerAuthConfig.layer({ password: Option.some("secret"), username: "kit" })
* ```
*/
export const Service =
<Self>() =>
<const Id extends string, const Fields extends ConfigMap>(id: Id, fields: Fields) => {
class ConfigTag extends Context.Service<Self, Shape<Fields>>()(id) {
static layer(input: Shape<Fields>) {
return Layer.succeed(this, this.of(input))
}
static get defaultLayer() {
return Layer.effect(
this,
Config.all(fields)
.asEffect()
.pipe(
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- Config.all preserves the field shape, but its conditional return type also supports iterable inputs.
Effect.map((config) => this.of(config as Shape<Fields>)),
),
)
}
}
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- The generated class carries typed static helpers.
return ConfigTag as ServiceClass<Self, Id, Shape<Fields>>
}
export * as ConfigService from "./config-service"
+15 -10
View File
@@ -1,4 +1,4 @@
import { Effect, Layer, ManagedRuntime } from "effect"
import { Effect, Fiber, Layer, ManagedRuntime } from "effect"
import * as Context from "effect/Context"
import { Instance } from "@/project/instance"
import { LocalContext } from "@/util/local-context"
@@ -24,15 +24,20 @@ export function attachWith<A, E, R>(effect: Effect.Effect<A, E, R>, refs: Refs):
}
export function attach<A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> {
try {
return attachWith(effect, {
instance: Instance.current,
workspace: WorkspaceContext.workspaceID,
})
} catch (err) {
if (!(err instanceof LocalContext.NotFound)) throw err
}
return effect
const workspace = WorkspaceContext.workspaceID
const instance = (() => {
try {
return Instance.current
} catch (err) {
if (!(err instanceof LocalContext.NotFound)) throw err
}
})()
if (instance && workspace !== undefined) return attachWith(effect, { instance, workspace })
const fiber = Fiber.getCurrent()
return attachWith(effect, {
instance: instance ?? (fiber ? Context.getReferenceUnsafe(fiber.context, InstanceRef) : undefined),
workspace: workspace ?? (fiber ? Context.getReferenceUnsafe(fiber.context, WorkspaceRef) : undefined),
})
}
export function makeRuntime<I, S, E>(service: Context.Service<I, S>, layer: Layer.Layer<I, E>) {
@@ -0,0 +1,38 @@
import { Context, Effect } from "effect"
type EffectMethod = (...args: ReadonlyArray<never>) => Effect.Effect<unknown, unknown, unknown>
type ServiceUse<Identifier, Shape> = {
readonly [Key in keyof Shape as Shape[Key] extends EffectMethod ? Key : never]: Shape[Key] extends (
...args: infer Args
) => infer Return
? Args extends ReadonlyArray<unknown>
? Return extends Effect.Effect<infer A, infer E, infer R>
? (...args: Args) => Effect.Effect<A, E, R | Identifier>
: never
: never
: never
}
export const serviceUse = <Identifier, Shape>(tag: Context.Service<Identifier, Shape>) => {
// This is the only dynamic boundary: TypeScript knows the accessor shape,
// but Proxy property names are runtime values.
const access = new Proxy(
{},
{
get: (_, key) => {
if (typeof key !== "string") return undefined
return (...args: unknown[]) =>
tag.use((service) => {
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- Proxy keys are checked at runtime.
const method = service[key as keyof Shape]
if (typeof method !== "function") return Effect.die(new Error(`Service method not found: ${key}`))
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- ServiceUse exposes only Effect-returning methods.
return (method as (...args: unknown[]) => Effect.Effect<unknown, unknown, unknown>)(...args)
})
},
},
)
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- Proxy implements the mapped accessor surface lazily.
return access as ServiceUse<Identifier, Shape>
}
+10 -10
View File
@@ -11,17 +11,17 @@ import fuzzysort from "fuzzysort"
import ignore from "ignore"
import path from "path"
import { Global } from "@opencode-ai/core/global"
import { Instance } from "../project/instance"
import { containsPath } from "../project/instance-context"
import * as Log from "@opencode-ai/core/util/log"
import { Protected } from "./protected"
import { Ripgrep } from "./ripgrep"
import { zod } from "@/util/effect-zod"
import { type DeepMutable, withStatics } from "@/util/schema"
import { NonNegativeInt, type DeepMutable, withStatics } from "@/util/schema"
export const Info = Schema.Struct({
path: Schema.String,
added: Schema.Int,
removed: Schema.Int,
added: NonNegativeInt,
removed: NonNegativeInt,
status: Schema.Literals(["added", "deleted", "modified"]),
})
.annotate({ identifier: "File" })
@@ -40,10 +40,10 @@ export const Node = Schema.Struct({
export type Node = DeepMutable<Schema.Schema.Type<typeof Node>>
const Hunk = Schema.Struct({
oldStart: Schema.Number,
oldLines: Schema.Number,
newStart: Schema.Number,
newLines: Schema.Number,
oldStart: NonNegativeInt,
oldLines: NonNegativeInt,
newStart: NonNegativeInt,
newLines: NonNegativeInt,
lines: Schema.Array(Schema.String),
})
@@ -508,7 +508,7 @@ export const layer = Layer.effect(
const ctx = yield* InstanceState.context
const full = path.join(ctx.directory, file)
if (!Instance.containsPath(full, ctx)) {
if (!containsPath(full, ctx)) {
throw new Error("Access denied: path escapes project directory")
}
@@ -595,7 +595,7 @@ export const layer = Layer.effect(
}
const resolved = dir ? path.join(ctx.directory, dir) : ctx.directory
if (!Instance.containsPath(resolved, ctx)) {
if (!containsPath(resolved, ctx)) {
throw new Error("Access denied: path escapes project directory")
}
+14 -14
View File
@@ -12,7 +12,7 @@ import * as Log from "@opencode-ai/core/util/log"
import { sanitizedProcessEnv } from "@opencode-ai/core/util/opencode-process"
import { which } from "@/util/which"
import { zod } from "@/util/effect-zod"
import { withStatics } from "@/util/schema"
import { NonNegativeInt, withStatics } from "@/util/schema"
const log = Log.create({ service: "ripgrep" })
const VERSION = "15.1.0"
@@ -27,19 +27,19 @@ const PLATFORM = {
} as const
const TimeStats = Schema.Struct({
secs: Schema.Number,
nanos: Schema.Number,
secs: NonNegativeInt,
nanos: NonNegativeInt,
human: Schema.String,
})
const Stats = Schema.Struct({
elapsed: TimeStats,
searches: Schema.Number,
searches_with_match: Schema.Number,
bytes_searched: Schema.Number,
bytes_printed: Schema.Number,
matched_lines: Schema.Number,
matches: Schema.Number,
searches: NonNegativeInt,
searches_with_match: NonNegativeInt,
bytes_searched: NonNegativeInt,
bytes_printed: NonNegativeInt,
matched_lines: NonNegativeInt,
matches: NonNegativeInt,
})
const PathText = Schema.Struct({
@@ -58,15 +58,15 @@ export const SearchMatch = Schema.Struct({
lines: Schema.Struct({
text: Schema.String,
}),
line_number: Schema.Number,
absolute_offset: Schema.Number,
line_number: NonNegativeInt,
absolute_offset: NonNegativeInt,
submatches: Schema.Array(
Schema.Struct({
match: Schema.Struct({
text: Schema.String,
}),
start: Schema.Number,
end: Schema.Number,
start: NonNegativeInt,
end: NonNegativeInt,
}),
),
}).pipe(withStatics((s) => ({ zod: zod(s) })))
@@ -80,7 +80,7 @@ const End = Schema.Struct({
type: Schema.Literal("end"),
data: Schema.Struct({
path: PathText,
binary_offset: Schema.NullOr(Schema.Number),
binary_offset: Schema.NullOr(NonNegativeInt),
stats: Stats,
}),
})
+10 -14
View File
@@ -10,7 +10,6 @@ import { BusEvent } from "@/bus/bus-event"
import { InstanceState } from "@/effect/instance-state"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Git } from "@/git"
import { Instance } from "@/project/instance"
import { lazy } from "@/util/lazy"
import { Config } from "@/config/config"
import { FileIgnore } from "./ignore"
@@ -76,25 +75,27 @@ export const layer = Layer.effect(
function* () {
if (yield* Flag.KILO_EXPERIMENTAL_DISABLE_FILEWATCHER) return
log.info("init", { directory: Instance.directory })
const ctx = yield* InstanceState.context
log.info("init", { directory: ctx.directory })
const backend = getBackend()
if (!backend) {
log.error("watcher backend not supported", { directory: Instance.directory, platform: process.platform })
log.error("watcher backend not supported", { directory: ctx.directory, platform: process.platform })
return
}
const w = watcher()
if (!w) return
log.info("watcher backend", { directory: Instance.directory, platform: process.platform, backend })
log.info("watcher backend", { directory: ctx.directory, platform: process.platform, backend })
const subs: ParcelWatcher.AsyncSubscription[] = []
yield* Effect.addFinalizer(() =>
Effect.promise(() => Promise.allSettled(subs.map((sub) => sub.unsubscribe()))),
)
const cb: ParcelWatcher.SubscribeCallback = Instance.bind((err, evts) => {
const cb: ParcelWatcher.SubscribeCallback = InstanceState.bind((err, evts) => {
if (err) return
for (const evt of evts) {
if (evt.type === "create") void Bus.publish(Event.Updated, { file: evt.path, event: "add" })
@@ -122,19 +123,14 @@ export const layer = Layer.effect(
const cfgIgnores = cfg.watcher?.ignore ?? []
if (yield* Flag.KILO_EXPERIMENTAL_FILEWATCHER) {
yield* subscribe(Instance.directory, [
...FileIgnore.PATTERNS,
...cfgIgnores,
...protecteds(Instance.directory),
])
yield* subscribe(ctx.directory, [...FileIgnore.PATTERNS, ...cfgIgnores, ...protecteds(ctx.directory)])
}
if (Instance.project.vcs === "git") {
if (ctx.project.vcs === "git") {
const result = yield* git.run(["rev-parse", "--git-dir"], {
cwd: Instance.project.worktree,
cwd: ctx.worktree,
})
const vcsDir =
result.exitCode === 0 ? path.resolve(Instance.project.worktree, result.text().trim()) : undefined
const vcsDir = result.exitCode === 0 ? path.resolve(ctx.worktree, result.text().trim()) : undefined
if (vcsDir && !cfgIgnores.includes(".git") && !cfgIgnores.includes(vcsDir)) {
const ignore = (yield* Effect.promise(() => readdir(vcsDir).catch(() => []))).filter(
(entry) => entry !== "HEAD",
+2 -2
View File
@@ -38,7 +38,7 @@ import { RemoteCommand } from "./cli/cmd/remote" // kilocode_change
import { DevSetupCommand, DevAliasCommand } from "./kilocode/cli/dev-setup" // kilocode_change
// kilocode_change start - Import telemetry, instance disposal, and legacy migration
import { Telemetry } from "@kilocode/kilo-telemetry"
import { Instance } from "./project/instance" // kilocode_change
import { InstanceStore } from "./project/instance-store" // kilocode_change
import { migrateLegacyKiloAuth, ENV_FEATURE, ENV_VERSION } from "@kilocode/kilo-gateway"
// kilocode_change - set feature for tracking. 'serve' is spawned by other services
@@ -319,7 +319,7 @@ try {
await Telemetry.shutdown()
// kilocode_change end
await Instance.disposeAll() // kilocode_change - safety net disposal (no-op if already disposed)
await InstanceStore.disposeAllInstances() // kilocode_change - safety net disposal (no-op if already disposed)
// Some subprocesses don't react properly to SIGTERM and similar signals.
// Most notably, some docker-container-based MCP servers don't handle such signals unless
-14
View File
@@ -1,14 +0,0 @@
declare module "jschardet" {
export interface Result {
encoding?: string
confidence?: number
}
export function detect(input: ArrayLike<number>): Result
const api: {
detect(input: ArrayLike<number>): Result
}
export default api
}
+46 -13
View File
@@ -5,6 +5,7 @@ import { Glob } from "@opencode-ai/core/util/glob"
import * as Truncate from "../../tool/truncate"
import { Config } from "../../config/config"
import { Instance } from "../../project/instance"
import { InstanceStore } from "../../project/instance-store"
import { makeRuntime } from "@/effect/run-service"
import z from "zod"
import path from "path"
@@ -142,6 +143,7 @@ function askGuard(mcp: Record<string, "allow" | "ask" | "deny"> = {}) {
grep: "allow",
glob: "allow",
list: "allow",
skill: "allow",
question: "allow",
webfetch: "allow",
websearch: "allow",
@@ -155,11 +157,36 @@ function askGuard(mcp: Record<string, "allow" | "ask" | "deny"> = {}) {
})
}
function planGuard(mcp: Record<string, "allow" | "ask" | "deny"> = {}) {
function denies(user: Permission.Ruleset) {
return user.filter((rule) => rule.action === "deny")
}
function askEditGuard() {
return Permission.fromConfig({ edit: "deny" })
}
// Upstream v1.14.33 builds Agent state outside the Instance ALS, so reading
// Instance.worktree here would crash. Thread worktree through from patchAgents
// instead.
function planEditRules(worktree: string) {
return {
"*": "deny" as const,
[path.join(".kilo", "plans", "*.md")]: "allow" as const,
[path.join(".opencode", "plans", "*.md")]: "allow" as const,
[path.relative(worktree, path.join(Global.Path.data, path.join("plans", "*.md")))]: "allow" as const,
}
}
function planEditGuard(worktree: string) {
return Permission.fromConfig({ edit: planEditRules(worktree) })
}
function planGuard(worktree: string, mcp: Record<string, "allow" | "ask" | "deny"> = {}) {
return Permission.fromConfig({
"*": "deny",
question: "allow",
suggest: "allow",
skill: "allow",
plan_exit: "allow",
bash: readOnlyBash,
read: {
@@ -180,12 +207,7 @@ function planGuard(mcp: Record<string, "allow" | "ask" | "deny"> = {}) {
[Truncate.GLOB]: "allow",
[path.join(Global.Path.data, "plans", "*")]: "allow",
},
edit: {
"*": "deny",
[path.join(".kilo", "plans", "*.md")]: "allow",
[path.join(".opencode", "plans", "*.md")]: "allow",
[path.relative(Instance.worktree, path.join(Global.Path.data, path.join("plans", "*.md")))]: "allow",
},
edit: planEditRules(worktree),
...mcp,
})
}
@@ -275,6 +297,8 @@ export function patchAgents(
user: Permission.Ruleset,
cfg: Config.Info,
kilo: KiloData,
worktree: string,
whitelistedDirs: string[],
) {
// Rename "build" → "code" for backward compatibility
if (agents.build) {
@@ -298,9 +322,10 @@ export function patchAgents(
description: "Plan mode. Can only edit plan files; all other filesystem mutations are denied.",
permission: Permission.merge(
defaults,
planGuard(worktree, kilo.mcpRules),
user,
planGuard(kilo.mcpRules),
user.filter((r: Permission.Rule) => r.action === "deny"),
planEditGuard(worktree),
denies(user),
),
}
}
@@ -317,6 +342,7 @@ export function patchAgents(
glob: "allow",
list: "allow",
bash: "allow",
skill: "allow",
webfetch: "allow",
websearch: "allow",
codesearch: "allow",
@@ -324,8 +350,13 @@ export function patchAgents(
semantic_search: "allow",
read: "allow",
external_directory: {
// Mirror upstream explore's shape: the outer "*": "deny" above wins
// over defaults' external_directory rules via findLast, so re-apply
// the full whitelist (Truncate.GLOB, tmp, skill, config, globalDirs)
// here. Upstream adds these inline in agent.ts; we do the same from
// within the patch.
"*": "ask",
[Truncate.GLOB]: "allow",
...Object.fromEntries(whitelistedDirs.map((dir) => [dir, "allow"])),
},
}),
user,
@@ -371,6 +402,7 @@ export function patchAgents(
glob: "allow",
list: "allow",
question: "allow",
skill: "allow",
suggest: "allow", // kilocode_change
task: "allow",
todoread: "allow",
@@ -402,9 +434,10 @@ export function patchAgents(
options: {},
permission: Permission.merge(
defaults,
user, // user before ask-specific so ask's deny+allowlist wins
askGuard(kilo.mcpRules),
user.filter((r: Permission.Rule) => r.action === "deny"), // re-apply user denies so explicit MCP blocks win over mcpRules
user,
askEditGuard(),
denies(user),
),
mode: "primary",
native: true,
@@ -485,5 +518,5 @@ export async function remove(name: string) {
if (!found) throw new RemoveError({ name, message: "no agent file found on disk" })
await Instance.dispose()
await InstanceStore.disposeInstance(Instance.current)
}
@@ -0,0 +1,337 @@
// kilocode_change - new file
/**
* KiloClaw chat slash-command autocomplete.
*
* Slim port of `cli/cmd/tui/component/prompt/autocomplete.tsx` — drops the
* `@`-mode branches (file mentions, agents, MCP resources, frecency,
* extmark wiring) and only handles `/` slash commands.
*
* Source of slash commands is supplied by the caller via the `slashes`
* prop. We intentionally do NOT pull from `useCommandDialog().slashes()`
* because that registry holds globally-registered commands across all
* routes (e.g. the home route's `/new` for sessions), which would clash
* with kiloclaw's own `/new` for conversations.
*
* Mounts as an absolutely-positioned popup above the chat textarea and
* suspends the global command keybinds while open so arrow keys drive the
* popup instead of triggering palette commands.
*/
import type { BoxRenderable, KeyEvent, ScrollBoxRenderable, TextareaRenderable } from "@opentui/core"
import fuzzysort from "fuzzysort"
import { Index, Show, createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js"
import { createStore } from "solid-js/store"
import { useTerminalDimensions } from "@opentui/solid"
import { SplitBorder } from "@tui/component/border"
import { useCommandDialog } from "@tui/component/dialog-command"
import { selectedForeground, useTheme } from "@tui/context/theme"
export type ClawAutocompleteRef = {
onInput: (value: string) => void
onCursorChange: () => void
onKeyDown: (e: KeyEvent) => void
dismiss: () => void
visible: boolean
}
export type ClawSlashOption = {
display: string
description?: string
aliases?: string[]
onSelect: () => void
}
export function ClawAutocomplete(props: {
value: string
slashes: () => ClawSlashOption[]
anchor: () => BoxRenderable | undefined
input: () => TextareaRenderable | undefined
ref: (ref: ClawAutocompleteRef) => void
}) {
const command = useCommandDialog()
const { theme } = useTheme()
const dimensions = useTerminalDimensions()
const [store, setStore] = createStore({
index: 0,
selected: 0,
visible: false as boolean,
})
const [positionTick, setPositionTick] = createSignal(0)
// Reposition the popup if the anchor moves (e.g., textarea grows).
createEffect(() => {
if (!store.visible) return
let last = { x: 0, y: 0, width: 0 }
const interval = setInterval(() => {
const a = props.anchor()
if (!a) return
if (a.x !== last.x || a.y !== last.y || a.width !== last.width) {
last = { x: a.x, y: a.y, width: a.width }
setPositionTick((t) => t + 1)
}
}, 50)
onCleanup(() => clearInterval(interval))
})
const position = createMemo(() => {
if (!store.visible) return { x: 0, y: 0, width: 0 }
dimensions()
positionTick()
const anchor = props.anchor()
if (!anchor) return { x: 0, y: 0, width: 0 }
const parent = anchor.parent
const parentX = parent?.x ?? 0
const parentY = parent?.y ?? 0
return {
x: anchor.x - parentX,
y: anchor.y - parentY,
width: anchor.width,
}
})
// Filter text — everything after the trigger up to the cursor.
const filter = createMemo(() => {
if (!store.visible) return ""
const inp = props.input()
if (!inp) return ""
props.value // reactive dep
return inp.getTextRange(store.index + 1, inp.cursorOffset)
})
const [search, setSearch] = createSignal("")
createEffect(() => {
setSearch(filter())
})
const options = createMemo<ClawSlashOption[]>(() => {
const all = props.slashes()
const q = search()
if (!q) return all
const result = fuzzysort.go(q, all, {
keys: [(o) => o.display.trimEnd(), "description", (o) => o.aliases?.join(" ") ?? ""],
limit: 10,
scoreFn: (results) => {
const first = results[0]
let score = results.score
if (first && first.target.startsWith("/" + q)) score *= 2
return score
},
})
return result.map((r) => r.obj)
})
// Reset selection when the filter changes
createEffect(() => {
filter()
setStore("selected", 0)
})
function move(direction: -1 | 1) {
if (!store.visible) return
const list = options()
if (!list.length) return
let next = store.selected + direction
if (next < 0) next = list.length - 1
if (next >= list.length) next = 0
moveTo(next)
}
function moveTo(next: number) {
setStore("selected", next)
if (!scroll) return
const viewportHeight = Math.min(height(), options().length)
const scrollBottom = scroll.scrollTop + viewportHeight
if (next < scroll.scrollTop) {
scroll.scrollBy(next - scroll.scrollTop)
} else if (next + 1 > scrollBottom) {
scroll.scrollBy(next + 1 - scrollBottom)
}
}
function select() {
const selected = options()[store.selected]
if (!selected) return
// Strip the `/<typed>` from the textarea so the dialog opens on a clean
// slate (mirrors the main Prompt's behavior when running a slash cmd).
const inp = props.input()
if (inp) {
const cursor = inp.logicalCursor
inp.deleteRange(0, 0, cursor.row, cursor.col)
}
hide()
selected.onSelect()
}
function show() {
command.keybinds(false)
setStore({
visible: true,
index: props.input()?.cursorOffset ?? 0,
})
}
function dismiss() {
if (!store.visible) return
command.keybinds(true)
setStore("visible", false)
}
function hide() {
dismiss()
}
onMount(() => {
props.ref({
get visible() {
return store.visible
},
dismiss() {
dismiss()
},
onInput(value) {
const inp = props.input()
if (!inp) return
if (store.visible) {
// Hide if cursor moved before trigger, whitespace appeared between
// trigger and cursor, or the input ceased being a single token.
if (
inp.cursorOffset <= store.index ||
inp.getTextRange(store.index, inp.cursorOffset).match(/\s/) ||
value.match(/^\S+\s+\S+\s*$/)
) {
hide()
}
return
}
// Auto-reopen after backspace deletes a leading space, etc.
const offset = inp.cursorOffset
if (offset === 0) return
if (value.startsWith("/") && !value.slice(0, offset).match(/\s/)) {
show()
setStore("index", 0)
}
},
onCursorChange() {
if (!store.visible) return
const inp = props.input()
if (!inp) return
const cursor = inp.cursorOffset
const value = inp.plainText
if (
cursor <= store.index ||
inp.getTextRange(store.index, cursor).match(/\s/) ||
value.match(/^\S+\s+\S+\s*$/)
) {
hide()
}
},
onKeyDown(e: KeyEvent) {
if (store.visible) {
const name = e.name?.toLowerCase()
const ctrlOnly = e.ctrl && !e.meta && !e.shift
const isNavUp = name === "up" || (ctrlOnly && name === "p")
const isNavDown = name === "down" || (ctrlOnly && name === "n")
if (isNavUp) {
move(-1)
e.preventDefault()
return
}
if (isNavDown) {
move(1)
e.preventDefault()
return
}
if (name === "escape") {
hide()
e.preventDefault()
return
}
if (name === "return" || name === "tab") {
select()
e.preventDefault()
return
}
if (name === "right") {
const inp = props.input()
if (inp && inp.cursorOffset <= store.index) {
hide()
return
}
}
return
}
if (e.name === "/") {
const inp = props.input()
if (inp && inp.cursorOffset === 0) show()
}
},
})
onCleanup(() => {
if (store.visible) command.keybinds(true)
})
})
const height = createMemo(() => {
const count = options().length || 1
if (!store.visible) return Math.min(10, count)
positionTick()
const a = props.anchor()
return Math.min(10, count, Math.max(1, a?.y ?? 1))
})
let scroll: ScrollBoxRenderable
return (
<box
visible={store.visible}
position="absolute"
top={position().y - height()}
left={position().x}
width={position().width}
zIndex={100}
{...SplitBorder}
borderColor={theme.border}
>
<scrollbox
ref={(r: ScrollBoxRenderable) => (scroll = r)}
backgroundColor={theme.backgroundMenu}
height={height()}
scrollbarOptions={{ visible: false }}
>
<Index
each={options()}
fallback={
<box paddingLeft={1} paddingRight={1}>
<text fg={theme.textMuted}>No matching commands</text>
</box>
}
>
{(option, index) => (
<box
paddingLeft={1}
paddingRight={1}
backgroundColor={index === store.selected ? theme.primary : undefined}
flexDirection="row"
onMouseDown={() => moveTo(index)}
onMouseUp={() => select()}
>
<text fg={index === store.selected ? selectedForeground(theme) : theme.text} flexShrink={0}>
{option().display}
</text>
<Show when={option().description}>
<text fg={index === store.selected ? selectedForeground(theme) : theme.textMuted} wrapMode="none">
{" "}
{option().description}
</text>
</Show>
</box>
)}
</Index>
</scrollbox>
</box>
)
}
+107 -36
View File
@@ -3,19 +3,23 @@
/**
* KiloClaw chat panel
*
* Renders a scrollable message list and a textarea input for chatting
* with the KiloClaw bot via Stream Chat.
* Renders a scrollable message list, a slash-command autocomplete popup,
* and a textarea for chatting with the KiloClaw bot via Kilo Chat.
* Conversation title and context-window usage live in the sidebar (mirrors
* the session route's layout).
*
* Visual style mirrors the session chat TUI (routes/session/index.tsx).
*/
import { createEffect, createMemo, For, Show } from "solid-js"
import { type KeyBinding, type MouseEvent, type TextareaRenderable } from "@opentui/core"
import { createEffect, createMemo, createSignal, For, Show } from "solid-js"
import { type BoxRenderable, type KeyBinding, type MouseEvent, type TextareaRenderable } from "@opentui/core"
import { useRenderer } from "@opentui/solid"
import { useTheme } from "@tui/context/theme"
import { SplitBorder, EmptyBorder } from "@tui/component/border"
import { useKV } from "@tui/context/kv"
import type { ChatMessage } from "./types"
import { Spinner } from "@tui/component/spinner"
import type { ChatMessage, TypingMember } from "./types"
import { ClawAutocomplete, type ClawAutocompleteRef, type ClawSlashOption } from "./autocomplete"
function UserMessageRow(props: { msg: ChatMessage; index: number }) {
const { theme } = useTheme()
@@ -64,45 +68,87 @@ export function ClawChat(props: {
loading: boolean
error: string | null
disabled: boolean
typingMembers: TypingMember[]
slashes: () => ClawSlashOption[]
botName: string
onSend: (text: string) => Promise<boolean>
}) {
const { theme } = useTheme()
const renderer = useRenderer()
const kv = useKV()
const [showScrollbar] = kv.signal("scrollbar_visible", true)
let input: TextareaRenderable
let input: TextareaRenderable | undefined
let inputAnchor: BoxRenderable | undefined
let auto: ClawAutocompleteRef | undefined
const [inputValue, setInputValue] = createSignal("")
const active = createMemo(() => !props.disabled && props.connected)
// Input is usable only when the instance is running, the WebSocket is
// connected, and the bot is online. Sending while the bot is offline
// would be silently dropped (no one to deliver to), so surface that as
// a disabled input rather than a message that vanishes.
const active = createMemo(() => !props.disabled && props.connected && props.online)
// Render the typing banner with friendly names. Bot members come in as
// `bot:kiloclaw:{sandboxId}` — the bot is the only non-self member in 1:1
// chats, so we resolve bot ids to the user-configured `botName` (which
// falls back to "KiloClaw" upstream) and render any human collaborators
// by their raw memberId.
const typingLabel = createMemo(() => {
const list = props.typingMembers
if (!list.length) return null
const names = list.map((m) => (m.memberId.startsWith("bot:") ? props.botName : m.memberId))
if (names.length === 1) return `${names[0]} is typing`
if (names.length === 2) return `${names[0]} and ${names[1]} are typing`
return `${names[0]} and ${names.length - 1} others are typing`
})
const placeholder = createMemo(() => {
if (props.error) return props.error
if (props.loading) return "Connecting..."
if (!props.connected) return "Chat unavailable"
if (props.disabled) return "Instance is stopped"
return ""
if (!props.online) return `${props.botName} is offline`
return "Type a message... (/ for commands)"
})
const submit = async () => {
if (!input) return
if (auto?.visible) return // let autocomplete consume Enter
const text = input.plainText.trim()
if (!text) return
if (!active()) return
const ok = await props.onSend(text)
if (ok) input.clear()
if (ok) {
input.clear()
setInputValue("")
}
}
createEffect(() => {
if (active()) input?.focus()
})
// Stream Chat WebSocket events fire outside OpenTUI's render cycle.
// Track props driven by external callbacks and explicitly request a repaint.
// Repaint when external state arrives (events fire outside OpenTUI's
// render cycle).
createEffect(() => {
props.messages.length
props.online
props.typingMembers.length
renderer.requestRender()
})
// Drive the textarea's traits + focus from active() and autocomplete
// visibility. We always render the textarea (even while loading or
// disconnected) so the input area keeps a stable height; `traits.suspend`
// disables interaction without changing layout. Mirrors DialogPrompt's
// suspend-then-blur pattern so the cursor visibly leaves while inactive.
createEffect(() => {
if (!input || input.isDestroyed) return
const suspend = !active()
const status = props.loading ? "LOADING" : props.disabled ? "OFFLINE" : undefined
input.traits = auto?.visible
? { capture: ["escape", "navigate", "submit", "tab"] as const, suspend, status }
: { suspend, status }
if (suspend) input.blur()
else input.focus()
})
return (
<box flexDirection="column" flexGrow={1} gap={1} paddingTop={1}>
{/* Messages */}
@@ -143,8 +189,15 @@ export function ClawChat(props: {
</For>
</scrollbox>
{/* Typing indicator */}
<Show when={typingLabel()}>
<box flexShrink={0} paddingLeft={2}>
<Spinner color={theme.textMuted}>{typingLabel()}</Spinner>
</box>
</Show>
{/* Input area */}
<box flexShrink={0}>
<box flexShrink={0} ref={(r: BoxRenderable) => (inputAnchor = r)}>
<box
border={["left"]}
borderColor={active() ? theme.primary : theme.textMuted}
@@ -162,26 +215,35 @@ export function ClawChat(props: {
backgroundColor={theme.backgroundElement}
flexGrow={1}
>
<Show when={active()} fallback={<text fg={theme.textMuted}>{placeholder()}</text>}>
<textarea
ref={(r: TextareaRenderable) => {
input = r
}}
placeholder="Type a message... (Enter to send)"
textColor={theme.text}
focusedTextColor={theme.text}
minHeight={2}
maxHeight={4}
cursorColor={theme.text}
focusedBackgroundColor={theme.backgroundElement}
onMouseDown={(e: MouseEvent) => e.target?.focus()}
keyBindings={[
{ name: "return", action: "submit" } satisfies KeyBinding,
{ name: "return", shift: true, action: "newline" } satisfies KeyBinding,
]}
onSubmit={submit}
/>
</Show>
<textarea
ref={(r: TextareaRenderable) => {
input = r
}}
placeholder={placeholder()}
placeholderColor={theme.textMuted}
textColor={active() ? theme.text : theme.textMuted}
focusedTextColor={active() ? theme.text : theme.textMuted}
minHeight={2}
maxHeight={4}
cursorColor={active() ? theme.text : theme.backgroundElement}
focusedBackgroundColor={theme.backgroundElement}
onMouseDown={(e: MouseEvent) => {
if (active()) e.target?.focus()
}}
onContentChange={() => {
if (!input) return
const value = input.plainText
setInputValue(value)
auto?.onInput(value)
}}
onCursorChange={() => auto?.onCursorChange()}
onKeyDown={(e) => auto?.onKeyDown(e)}
keyBindings={[
{ name: "return", action: "submit" } satisfies KeyBinding,
{ name: "return", shift: true, action: "newline" } satisfies KeyBinding,
]}
onSubmit={submit}
/>
</box>
</box>
<box
@@ -211,6 +273,15 @@ export function ClawChat(props: {
/>
</box>
</box>
{/* Slash autocomplete popup, anchored to the textarea wrapper */}
<ClawAutocomplete
value={inputValue()}
slashes={props.slashes}
anchor={() => inputAnchor}
input={() => input}
ref={(r) => (auto = r)}
/>
</box>
)
}
+445 -114
View File
@@ -1,161 +1,492 @@
// kilocode_change - new file
/**
* KiloClaw Stream Chat client wrapper
* KiloClaw Kilo Chat client wrapper for the TUI.
*
* Headless Stream Chat JS SDK integration for the TUI.
* Uses the same channel/credentials as the web dashboard's ChatTab.
* Hosts the HTTP and WebSocket clients, exposes a multi-conversation API
* (list/create/select/rename/delete), and translates Kilo Chat events for
* the active conversation into the legacy `ChatMessage` shape that the
* existing single-pane renderer consumes.
*
* stream-chat is dynamically imported to avoid crashing Bun at module
* load time (follow-redirects uses Error.captureStackTrace in a way
* that is incompatible with Bun's runtime).
* Switching the active conversation rotates the WebSocket subscription so
* we only ever stream events for the conversation the user is looking at.
*/
import type { ChatCredentials, ChatMessage } from "./types"
import type {
BotStatusEvent,
ChatMessage,
ChatToken,
ContentBlock,
ConversationActivityEvent,
ConversationListItem,
ConversationRenamedEvent,
ConversationStatusEvent,
ConversationStatusRecord,
Message,
MessageCreatedEvent,
MessageUpdatedEvent,
TypingEvent,
TypingMember,
} from "./types"
import { KiloChatClient } from "./kilo-chat-client"
import { EventServiceClient } from "./event-service-client"
import * as Log from "@opencode-ai/core/util/log"
const log = Log.create({ service: "claw-chat" })
export type ConversationStatusListener = (status: ConversationStatusRecord | null) => void
export type ConversationsListener = (conversations: ConversationListItem[]) => void
export type ActiveConversationListener = (id: string | null) => void
export type TypingMembersListener = (members: TypingMember[]) => void
export type ClawChatClient = {
channel: any
disconnect: () => Promise<void>
// Conversations
listConversations: () => ConversationListItem[]
activeConversationId: () => string | null
selectConversation: (
conversationId: string,
) => Promise<{ messages: ChatMessage[]; status: ConversationStatusRecord | null }>
createConversation: (title?: string) => Promise<string>
renameConversation: (conversationId: string, title: string) => Promise<void>
deleteConversation: (conversationId: string) => Promise<void>
refreshConversations: () => Promise<ConversationListItem[]>
// Active conversation
send: (text: string) => Promise<void>
loadHistory: (conversationId?: string) => Promise<ChatMessage[]>
initialBotOnline: () => boolean
conversationStatus: () => ConversationStatusRecord | null
typingMembers: () => TypingMember[]
// Subscriptions
onMessage: (cb: (msg: ChatMessage) => void) => () => void
onMessageUpdated: (cb: (msg: ChatMessage) => void) => () => void
onPresence: (cb: (online: boolean) => void) => () => void
onConversations: (cb: ConversationsListener) => () => void
onActiveConversation: (cb: ActiveConversationListener) => () => void
onConversationStatus: (cb: ConversationStatusListener) => () => void
onTypingMembers: (cb: TypingMembersListener) => () => void
}
export function botId(creds: ChatCredentials): string {
return `bot-${creds.channelId.replace(/^default-/, "")}`
function blocksToText(content: ContentBlock[]): string {
let out = ""
for (const b of content) {
if (b.type === "text") out += b.text
}
return out
}
function toMessage(raw: any, bot: string): ChatMessage {
const ULID_TIME_LEN = 10
const ENCODING = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
function ulidToTimestamp(id: string): number {
if (!id || id.length < ULID_TIME_LEN) return Date.now()
const time = id.slice(0, ULID_TIME_LEN).toUpperCase()
let ts = 0
for (const ch of time) {
const idx = ENCODING.indexOf(ch)
if (idx === -1) return Date.now()
ts = ts * ENCODING.length + idx
}
return ts
}
function toChatMessage(m: Message, currentUserId: string): ChatMessage {
return {
id: raw.id ?? "",
text: raw.text ?? "",
user: raw.user?.id ?? raw.user_id ?? "",
created: raw.created_at ? new Date(raw.created_at) : new Date(),
bot: (raw.user?.id ?? raw.user_id ?? "") === bot,
id: m.id,
text: m.deleted ? "[deleted message]" : blocksToText(m.content),
user: m.senderId,
created: new Date(ulidToTimestamp(m.id)),
bot: m.senderId.startsWith("bot:") || m.senderId !== currentUserId,
}
}
/**
* Apply Bun compatibility patches for stream-chat and its dependencies.
*
* 1. Error.captureStackTrace — follow-redirects passes a plain object with
* Error.prototype in its chain which crashes Bun.
* 2. net.Socket.prototype.destroy — Bun throws "First argument must be an
* Error object" when destroy() receives a non-Error. The ws library and
* stream-chat internals can trigger this during WebSocket lifecycle events.
*
* Both patches are applied once and remain active for the process lifetime.
*/
let patched = false
function applyBunPatches() {
if (patched) return
patched = true
function toChatMessageFromCreated(e: MessageCreatedEvent, currentUserId: string): ChatMessage {
return {
id: e.messageId,
text: blocksToText(e.content),
user: e.senderId,
created: new Date(ulidToTimestamp(e.messageId)),
bot: e.senderId.startsWith("bot:") || e.senderId !== currentUserId,
}
}
const orig = Error.captureStackTrace
if (orig) {
Error.captureStackTrace = function safe(target: any, ctor?: Function) {
try {
return orig.call(Error, target, ctor)
} catch {
// Bun throws when target is not a real Error (e.g. follow-redirects
// CustomError which has Error.prototype in its chain but is not
// constructed via `new Error()`). Silently skip.
}
} as typeof Error.captureStackTrace
function toChatMessageFromUpdated(
e: MessageUpdatedEvent,
currentUserId: string,
senderHint: string | null,
): ChatMessage {
const sender = senderHint ?? "bot"
return {
id: e.messageId,
text: blocksToText(e.content),
user: sender,
created: new Date(ulidToTimestamp(e.messageId)),
bot: sender.startsWith("bot:") || sender !== currentUserId,
}
}
export type ConnectInput = {
envelope: ChatToken
sandboxId: string
currentUserId: string
}
export async function connect(input: ConnectInput): Promise<ClawChatClient> {
const events = new EventServiceClient({
url: input.envelope.eventServiceUrl,
getToken: async () => input.envelope.token,
})
const chat = new KiloChatClient({
baseUrl: input.envelope.kiloChatUrl,
getToken: async () => input.envelope.token,
})
log.info("connecting to event-service")
await events.connect()
// Subscribe to sandbox-level context for conversation.* + bot.status events
const sandboxCtx = `/kiloclaw/${input.sandboxId}`
events.subscribe([sandboxCtx])
// Per-message sender cache so message.updated events know who the sender
// was (the event payload only carries messageId + content). Indexed by
// messageId — each conversation's messages live in their own ULID space.
const senderCache = new Map<string, string>()
// Conversation list state (refreshed reactively from server events).
let conversations: ConversationListItem[] = []
let activeId: string | null = null
let activeStatus: ConversationStatusRecord | null = null
let activeCtx: string | null = null
// Typing state for the active conversation. memberId → expiresAt epoch.
// Entries auto-expire after TYPING_TIMEOUT_MS in case typing.stop is missed.
const TYPING_TIMEOUT_MS = 5_000
const typing = new Map<string, number>()
let typingTimer: ReturnType<typeof setInterval> | null = null
const messageListeners = new Set<(msg: ChatMessage) => void>()
const messageUpdatedListeners = new Set<(msg: ChatMessage) => void>()
const presenceListeners = new Set<(online: boolean) => void>()
const conversationsListeners = new Set<ConversationsListener>()
const activeListeners = new Set<ActiveConversationListener>()
const statusListeners = new Set<ConversationStatusListener>()
const typingListeners = new Set<TypingMembersListener>()
function emit<T>(set: Set<(v: T) => void>, value: T): void {
for (const cb of set) cb(value)
}
try {
const net = require("net")
const origDestroy = net.Socket.prototype.destroy
net.Socket.prototype.destroy = function (err: any, cb: any) {
if (err != null && !(err instanceof Error)) {
err = new Error(String(err))
}
return origDestroy.call(this, err, cb)
async function loadConversations(): Promise<ConversationListItem[]> {
const res = await chat.listConversations({ sandboxId: input.sandboxId, limit: 50 })
conversations = res.conversations
emit(conversationsListeners, conversations)
return conversations
}
function snapshotTyping(): TypingMember[] {
const out: TypingMember[] = []
for (const [memberId, expiresAt] of typing) {
out.push({ memberId, at: expiresAt - TYPING_TIMEOUT_MS })
}
} catch {
// net module unavailable — skip socket patch
}
}
async function loadStreamChat() {
applyBunPatches()
const mod = await import("stream-chat")
return mod.StreamChat
}
export async function connect(creds: ChatCredentials): Promise<ClawChatClient> {
log.info("loading stream-chat")
const StreamChat = await loadStreamChat()
log.info("getInstance", { key: creds.apiKey?.substring(0, 8) + "..." })
const client = StreamChat.getInstance(creds.apiKey)
// getInstance returns a singleton — ensure any stale connection is
// cleaned up before (re-)connecting so navigate-away-and-back works.
if (client.userID) {
log.info("disconnecting stale user", { user: client.userID })
await client.disconnectUser()
return out
}
log.info("connectUser", { user: creds.userId })
await client.connectUser({ id: creds.userId }, creds.userToken)
function emitTyping(): void {
emit(typingListeners, snapshotTyping())
}
log.info("watching channel", { channel: creds.channelId })
const channel = client.channel("messaging", creds.channelId)
await channel.watch({ presence: true })
function clearTyping(): void {
if (typing.size === 0) return
typing.clear()
emitTyping()
}
log.info("connected successfully")
const bot = botId(creds)
function pruneTyping(): void {
const now = Date.now()
let removed = false
for (const [memberId, expiresAt] of typing) {
if (expiresAt <= now) {
typing.delete(memberId)
removed = true
}
}
if (removed) emitTyping()
if (typing.size === 0 && typingTimer !== null) {
clearInterval(typingTimer)
typingTimer = null
}
}
function startTyping(memberId: string): void {
typing.set(memberId, Date.now() + TYPING_TIMEOUT_MS)
if (typingTimer === null) {
typingTimer = setInterval(pruneTyping, 1_000)
}
emitTyping()
}
function stopTyping(memberId: string): void {
if (!typing.delete(memberId)) return
emitTyping()
if (typing.size === 0 && typingTimer !== null) {
clearInterval(typingTimer)
typingTimer = null
}
}
function setActive(id: string | null): void {
if (activeId === id) return
if (activeCtx) {
events.unsubscribe([activeCtx])
activeCtx = null
}
activeId = id
activeStatus = null
clearTyping()
if (id) {
activeCtx = `/kiloclaw/${input.sandboxId}/${id}`
events.subscribe([activeCtx])
}
emit(activeListeners, activeId)
emit(statusListeners, activeStatus)
}
// Initial bot status snapshot
const initialOnline = await chat
.getBotStatus(input.sandboxId)
.then((s) => s.status?.online ?? false)
.catch((err) => {
log.warn("getBotStatus failed", { error: (err as Error)?.message ?? String(err) })
return false
})
// Initial conversation list
await loadConversations().catch((err) => {
log.warn("listConversations failed", { error: (err as Error)?.message ?? String(err) })
})
// ── Sandbox-scoped event handlers ──────────────────────────────────
events.on("conversation.created", (ctx) => {
if (ctx !== sandboxCtx) return
void loadConversations()
})
events.on("conversation.renamed", (ctx, e: ConversationRenamedEvent) => {
if (ctx !== sandboxCtx) return
conversations = conversations.map((c) => (c.conversationId === e.conversationId ? { ...c, title: e.title } : c))
emit(conversationsListeners, conversations)
})
events.on("conversation.left", (ctx, e) => {
if (ctx !== sandboxCtx) return
conversations = conversations.filter((c) => c.conversationId !== e.conversationId)
emit(conversationsListeners, conversations)
if (activeId === e.conversationId) setActive(null)
})
events.on("conversation.activity", (ctx, e: ConversationActivityEvent) => {
if (ctx !== sandboxCtx) return
conversations = conversations.map((c) =>
c.conversationId === e.conversationId ? { ...c, lastActivityAt: e.lastActivityAt } : c,
)
emit(conversationsListeners, conversations)
})
events.on("bot.status", (ctx, e: BotStatusEvent) => {
if (ctx !== sandboxCtx) return
if (e.sandboxId !== input.sandboxId) return
emit(presenceListeners, e.online)
})
// ── Conversation-scoped event handlers ─────────────────────────────
// A fresh message implicitly stops the sender's typing indicator (the bot
// streams via message.updated, so its typing.stop arrives when the next
// bot message id appears). Handle both in a single listener to avoid
// duplicating the activeCtx filter.
events.on("message.created", (ctx, e: MessageCreatedEvent) => {
if (ctx !== activeCtx) return
senderCache.set(e.messageId, e.senderId)
if (activeId) trackLastSeen(activeId, e.messageId)
if (e.senderId !== input.currentUserId) stopTyping(e.senderId)
emit(messageListeners, toChatMessageFromCreated(e, input.currentUserId))
})
events.on("message.updated", (ctx, e: MessageUpdatedEvent) => {
if (ctx !== activeCtx) return
const sender = senderCache.get(e.messageId) ?? null
emit(messageUpdatedListeners, toChatMessageFromUpdated(e, input.currentUserId, sender))
})
events.on("conversation.status", (ctx, e: ConversationStatusEvent) => {
if (ctx !== activeCtx) return
if (e.conversationId !== activeId) return
activeStatus = {
conversationId: e.conversationId,
contextTokens: e.contextTokens,
contextWindow: e.contextWindow,
model: e.model,
provider: e.provider,
at: e.at,
updatedAt: Date.now(),
}
emit(statusListeners, activeStatus)
})
events.on("typing", (ctx, e: TypingEvent) => {
if (ctx !== activeCtx) return
if (e.memberId === input.currentUserId) return
startTyping(e.memberId)
})
events.on("typing.stop", (ctx, e: TypingEvent) => {
if (ctx !== activeCtx) return
if (e.memberId === input.currentUserId) return
stopTyping(e.memberId)
})
// Latest server-confirmed message id per conversation. Used to satisfy the
// mark-read endpoint's `lastSeenMessageId` requirement without re-listing.
const lastSeenByConv = new Map<string, string>()
function trackLastSeen(conversationId: string, messageId: string): void {
if (!messageId || messageId.startsWith("pending-")) return
const prev = lastSeenByConv.get(conversationId)
if (!prev || prev < messageId) lastSeenByConv.set(conversationId, messageId)
}
async function loadHistory(conversationId?: string): Promise<ChatMessage[]> {
const id = conversationId ?? activeId
if (!id) return []
const res = await chat.listMessages(id, { limit: 50 })
const ascending = [...res.messages].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
for (const m of ascending) {
senderCache.set(m.id, m.senderId)
trackLastSeen(id, m.id)
}
return ascending.map((m) => toChatMessage(m, input.currentUserId))
}
async function selectConversation(conversationId: string) {
setActive(conversationId)
const msgs = await loadHistory(conversationId)
// The user may have switched conversations while we awaited. Only
// publish the status via listeners if it still matches the active one,
// but always return the status we fetched for the requested id so the
// caller can use it if they need to.
const status = await chat.getConversationStatus(conversationId).then(
(res) => res.status ?? null,
(err) => {
log.warn("getConversationStatus failed", { error: (err as Error)?.message ?? String(err) })
return null
},
)
if (activeId === conversationId) {
activeStatus = status
emit(statusListeners, activeStatus)
}
const lastSeen = lastSeenByConv.get(conversationId)
if (lastSeen) {
await chat.markConversationRead(conversationId, { lastSeenMessageId: lastSeen }).catch((err) => {
log.warn("markConversationRead failed", { error: (err as Error)?.message ?? String(err) })
})
}
return { messages: msgs, status }
}
async function createConversation(title?: string): Promise<string> {
const res = await chat.createConversation({ sandboxId: input.sandboxId, title })
await loadConversations()
return res.conversationId
}
return {
channel,
async disconnect() {
await client.disconnectUser()
if (typingTimer !== null) {
clearInterval(typingTimer)
typingTimer = null
}
typing.clear()
events.disconnect()
},
listConversations() {
return conversations
},
activeConversationId() {
return activeId
},
typingMembers() {
return snapshotTyping()
},
async refreshConversations() {
return await loadConversations()
},
selectConversation,
createConversation,
async renameConversation(conversationId, title) {
conversations = conversations.map((c) => (c.conversationId === conversationId ? { ...c, title } : c))
emit(conversationsListeners, conversations)
await chat.renameConversation(conversationId, title)
},
async deleteConversation(conversationId) {
await chat.leaveConversation(conversationId)
conversations = conversations.filter((c) => c.conversationId !== conversationId)
emit(conversationsListeners, conversations)
if (activeId === conversationId) setActive(null)
},
async send(text: string) {
await channel.sendMessage({ text })
const trimmed = text.trim()
if (!trimmed) return
if (!activeId) {
// Auto-create a conversation on first send if none is active.
const id = await createConversation()
await selectConversation(id)
}
if (!activeId) return
await chat.sendMessage({
conversationId: activeId,
content: [{ type: "text", text: trimmed }],
})
},
loadHistory,
initialBotOnline() {
return initialOnline
},
conversationStatus() {
return activeStatus
},
onMessage(cb) {
const handler = (event: any) => {
if (event.message) cb(toMessage(event.message, bot))
}
channel.on("message.new", handler)
return () => channel.off("message.new", handler)
messageListeners.add(cb)
return () => messageListeners.delete(cb)
},
onMessageUpdated(cb) {
const handler = (event: any) => {
if (event.message) cb(toMessage(event.message, bot))
}
channel.on("message.updated", handler)
return () => channel.off("message.updated", handler)
messageUpdatedListeners.add(cb)
return () => messageUpdatedListeners.delete(cb)
},
onPresence(cb) {
const handler = (event: any) => {
if (event.user?.id === bot) {
cb(event.user.online ?? false)
}
}
client.on("user.presence.changed", handler)
return () => client.off("user.presence.changed", handler)
presenceListeners.add(cb)
return () => presenceListeners.delete(cb)
},
onConversations(cb) {
conversationsListeners.add(cb)
return () => conversationsListeners.delete(cb)
},
onActiveConversation(cb) {
activeListeners.add(cb)
return () => activeListeners.delete(cb)
},
onConversationStatus(cb) {
statusListeners.add(cb)
return () => statusListeners.delete(cb)
},
onTypingMembers(cb) {
typingListeners.add(cb)
return () => typingListeners.delete(cb)
},
}
}
export async function history(channel: any, bot: string): Promise<ChatMessage[]> {
const state = channel.state.messages
return state.map((raw: any) => toMessage(raw, bot))
}
/**
* Read the bot's initial online status from channel member state.
* Mirrors the cloud's `useBotOnlineStatus` which reads
* `channel.state.members[botUserId]?.user?.online`.
*/
export function presence(channel: any, bot: string): boolean {
const member = channel.state.members?.[bot]
return !!member?.user?.online
}
@@ -0,0 +1,130 @@
// kilocode_change - new file
/**
* Conversation selector for the KiloClaw chat.
*
* Mirrors the session-list dialog (`cli/cmd/tui/component/dialog-session-list.tsx`):
* - DialogSelect-backed list, grouped by date bucket
* - "current" marker on the active conversation
* - inline rename via DialogPrompt (replace pattern)
* - two-press delete confirmation with `theme.error` background
*/
import { DialogSelect } from "@tui/ui/dialog-select"
import { DialogPrompt } from "@tui/ui/dialog-prompt"
import { useDialog } from "@tui/ui/dialog"
import { useKeybind } from "@tui/context/keybind"
import { useTheme } from "@tui/context/theme"
import { useToast } from "@tui/ui/toast"
import { Locale } from "@/util/locale"
import { Keybind } from "@/util/keybind"
import { createMemo, createSignal, onMount } from "solid-js"
import type { ClawChat } from "./hooks"
type Props = {
chat: ClawChat
}
export function DialogConversationList(props: Props) {
const dialog = useDialog()
const keybind = useKeybind()
const { theme } = useTheme()
const toast = useToast()
const [toDelete, setToDelete] = createSignal<string>()
const options = createMemo(() => {
const today = new Date().toDateString()
const list = [...props.chat.conversations()]
list.sort((a, b) => {
const ax = a.lastActivityAt ?? a.joinedAt
const bx = b.lastActivityAt ?? b.joinedAt
return bx - ax
})
return list.map((c) => {
const ts = c.lastActivityAt ?? c.joinedAt
const date = new Date(ts)
let category = date.toDateString()
if (category === today) category = "Today"
const isDeleting = toDelete() === c.conversationId
return {
title: isDeleting ? `Press ${keybind.print("session_delete")} again to confirm` : (c.title ?? "Untitled"),
bg: isDeleting ? theme.error : undefined,
value: c.conversationId,
category,
footer: Locale.time(ts),
}
})
})
onMount(() => {
dialog.setSize("large")
})
return (
<DialogSelect
title="KiloClaw Conversations"
options={options()}
current={props.chat.activeConversationId() ?? undefined}
onMove={() => setToDelete(undefined)}
onSelect={async (option) => {
await props.chat.selectConversation(option.value)
dialog.clear()
}}
keybind={[
{
keybind: keybind.all.session_delete?.[0],
title: "delete",
onTrigger: async (option) => {
if (toDelete() === option.value) {
// `deleteConversation` already catches internally and returns
// a boolean; its outer promise never rejects here.
const ok = await props.chat.deleteConversation(option.value)
if (!ok) {
toast.show({
variant: "error",
title: "Failed to delete conversation",
message: "Please try again.",
})
}
setToDelete(undefined)
return
}
setToDelete(option.value)
},
},
{
keybind: keybind.all.session_rename?.[0],
title: "rename",
onTrigger: async (option) => {
const item = props.chat.conversations().find((c) => c.conversationId === option.value)
const list = () => dialog.replace(() => <DialogConversationList chat={props.chat} />)
dialog.replace(() => (
<DialogPrompt
title="Rename Conversation"
value={item?.title ?? ""}
placeholder="Enter a title"
onConfirm={async (value) => {
const trimmed = value.trim()
if (trimmed) {
await props.chat.renameConversation(option.value, trimmed)
}
list()
}}
onCancel={list}
/>
))
},
},
{
keybind: Keybind.parse("ctrl+n")[0],
title: "new",
side: "right",
onTrigger: async () => {
await props.chat.newConversation()
dialog.clear()
},
},
]}
/>
)
}
@@ -0,0 +1,379 @@
// kilocode_change - new file
/**
* Event Service WebSocket client for the TUI.
*
* Minimal inline port of `@kilocode/event-service` (cloud monorepo).
* Connects via a two-step ticket flow:
* 1. POST `/connect-ticket` with `Authorization: Bearer <JWT>` to mint a
* single-use ticket (30 s TTL).
* 2. Open WebSocket to `/connect?ticket=<ticket>` with subprotocol
* `kilo.events.v1`.
*
* Uses the global `WebSocket` constructor (Bun, Node 22+, browsers).
*/
import type { KiloChatEventMap, KiloChatEventName } from "./types"
const WS_SUBPROTOCOL = "kilo.events.v1"
const HANDSHAKE_TIMEOUT_MS = 10_000
const PING_INTERVAL_MS = 15_000
const TICKET_FETCH_TIMEOUT_MS = 10_000
export class WebSocketAuthError extends Error {
constructor(message = "WebSocket authentication failed") {
super(message)
this.name = "WebSocketAuthError"
}
}
export class WebSocketConnectError extends Error {
constructor(
message: string,
public readonly code: number,
) {
super(message)
this.name = "WebSocketConnectError"
}
}
export class HandshakeTimeoutError extends Error {
constructor() {
super("WebSocket handshake timed out")
this.name = "HandshakeTimeoutError"
}
}
// Close codes that signal the server rejected us for auth/policy reasons
// and reconnecting with the same token is pointless. Everything else
// (including 1006 "abnormal closure" from flaky networks) is transient.
function isAuthCloseCode(code: number): boolean {
if (code === 1008) return true // Policy Violation
if (code === 4401 || code === 4403) return true // Custom auth rejection
return false
}
export type EventHandler = (context: string, payload: unknown) => void
export type EventServiceConfig = {
url: string
getToken: () => Promise<string>
onUnauthorized?: () => void
}
/**
* The event-service base URL is configured as a WebSocket URL (`wss://…` /
* `ws://…`) but the connect-ticket endpoint is a plain HTTP request. Strip
* the trailing slash and swap the protocol so `fetch()` accepts the URL.
*/
function toHttpBase(wsBase: string): string {
const trimmed = wsBase.replace(/\/$/, "")
if (trimmed.startsWith("wss://")) return "https://" + trimmed.slice(6)
if (trimmed.startsWith("ws://")) return "http://" + trimmed.slice(5)
return trimmed
}
export class EventServiceClient {
private readonly url: string
private readonly getToken: () => Promise<string>
private readonly onUnauthorized: (() => void) | undefined
private ws: WebSocket | null = null
private connected = false
private destroyed = false
private reconnectAttempts = 0
private hasConnectedBefore = false
private reconnectTimer: ReturnType<typeof setTimeout> | null = null
private pingTimer: ReturnType<typeof setInterval> | null = null
private handshakeTimer: ReturnType<typeof setTimeout> | null = null
private abortHandshake: ((err: Error) => void) | null = null
private eventHandlers = new Map<string, Set<EventHandler>>()
private activeContexts = new Set<string>()
private reconnectHandlers = new Set<() => void>()
constructor(config: EventServiceConfig) {
this.url = config.url
this.getToken = config.getToken
this.onUnauthorized = config.onUnauthorized
}
async connect(): Promise<void> {
this.destroyed = false
this.reconnectAttempts = 0
if (this.reconnectTimer !== null) {
clearTimeout(this.reconnectTimer)
this.reconnectTimer = null
}
try {
await this.connectOnce()
} catch (err) {
if (this.handleAuthFailure(err)) return
if (!this.destroyed) this.scheduleReconnect()
}
}
disconnect(): void {
this.destroyed = true
if (this.reconnectTimer !== null) {
clearTimeout(this.reconnectTimer)
this.reconnectTimer = null
}
this.clearHandshakeTimer()
if (this.abortHandshake) {
this.abortHandshake(new Error("disconnected"))
}
if (this.ws) {
this.ws.close()
this.ws = null
}
this.stopPing()
this.connected = false
}
isConnected(): boolean {
return this.connected && this.ws !== null && this.ws.readyState === WebSocket.OPEN
}
subscribe(contexts: string[]): void {
for (const ctx of contexts) this.activeContexts.add(ctx)
if (this.isConnected()) {
this.sendJson({ type: "context.subscribe", contexts })
}
}
unsubscribe(contexts: string[]): void {
for (const ctx of contexts) this.activeContexts.delete(ctx)
if (this.isConnected()) {
this.sendJson({ type: "context.unsubscribe", contexts })
}
}
on<N extends KiloChatEventName>(event: N, handler: (ctx: string, payload: KiloChatEventMap[N]) => void): () => void {
const set = this.eventHandlers.get(event) ?? new Set<EventHandler>()
const wrapped: EventHandler = (ctx, payload) => handler(ctx, payload as KiloChatEventMap[N])
set.add(wrapped)
this.eventHandlers.set(event, set)
return () => {
set.delete(wrapped)
if (set.size === 0) this.eventHandlers.delete(event)
}
}
onReconnect(handler: () => void): () => void {
this.reconnectHandlers.add(handler)
return () => this.reconnectHandlers.delete(handler)
}
// ── private ────────────────────────────────────────────────────────
private handleAuthFailure(err: unknown): boolean {
if (err instanceof WebSocketAuthError) {
this.destroyed = true
if (this.reconnectTimer !== null) {
clearTimeout(this.reconnectTimer)
this.reconnectTimer = null
}
this.onUnauthorized?.()
return true
}
return false
}
private async connectOnce(): Promise<void> {
if (this.ws) {
const old = this.ws
this.ws = null
old.close()
}
const token = await this.getToken()
const ticket = await this.fetchTicket(token)
return new Promise<void>((resolve, reject) => {
const ws = new WebSocket(`${this.url}/connect?ticket=${encodeURIComponent(ticket)}`, [WS_SUBPROTOCOL])
this.ws = ws
let settled = false
const settleResolve = () => {
if (settled) return
settled = true
this.clearHandshakeTimer()
this.abortHandshake = null
resolve()
}
const settleReject = (err: Error) => {
if (settled) return
settled = true
this.clearHandshakeTimer()
this.abortHandshake = null
reject(err)
}
this.abortHandshake = settleReject
this.handshakeTimer = setTimeout(() => {
this.handshakeTimer = null
if (this.ws === ws) ws.close(1000, "handshake-timeout")
settleReject(new HandshakeTimeoutError())
}, HANDSHAKE_TIMEOUT_MS)
ws.addEventListener("open", () => {
const isReconnect = this.hasConnectedBefore
this.connected = true
this.hasConnectedBefore = true
this.reconnectAttempts = 0
this.resubscribeContexts()
if (isReconnect) {
for (const h of this.reconnectHandlers) h()
}
settleResolve()
this.startPing()
})
ws.addEventListener("message", (event: MessageEvent) => {
this.handleMessage(String(event.data))
})
ws.addEventListener("close", (event: CloseEvent) => {
if (this.ws !== ws) return
const wasConnected = this.connected
this.connected = false
this.stopPing()
this.clearHandshakeTimer()
// A handshake failure always fires `close` after `error`, so we
// settle here with a classification based on the close code:
// explicit auth/policy codes → fatal; anything else → transient
// and the caller (`connect`) will schedule a reconnect.
if (!wasConnected) {
if (isAuthCloseCode(event.code)) {
settleReject(new WebSocketAuthError())
} else {
settleReject(
new WebSocketConnectError(`WebSocket closed before open: ${event.code} ${event.reason}`, event.code),
)
}
return
}
if (!this.destroyed) this.scheduleReconnect()
})
ws.addEventListener("error", () => {
// Swallowed: the `close` event fires right after and carries the
// close code we need to distinguish auth failures from network
// blips. Settling here loses that context.
})
})
}
/**
* Mint a single-use connection ticket. The event-service issues a 30 s ticket
* scoped to the bearer JWT; the WebSocket upgrade then consumes it.
*
* `this.url` is the WebSocket base (`wss://…` or `ws://…`); `fetch()` only
* accepts `http(s)`, so we rewrite the protocol before the HTTP call.
*/
private async fetchTicket(token: string): Promise<string> {
const ctrl = new AbortController()
const timer = setTimeout(() => ctrl.abort(), TICKET_FETCH_TIMEOUT_MS)
try {
const res = await fetch(toHttpBase(this.url) + "/connect-ticket", {
method: "POST",
headers: { Authorization: `Bearer ${token}` },
signal: ctrl.signal,
})
if (res.status === 401 || res.status === 403) {
throw new WebSocketAuthError(`Event-service rejected ticket request: ${res.status}`)
}
if (!res.ok) {
throw new WebSocketConnectError(`Failed to mint event-service ticket: ${res.status}`, res.status)
}
const body = (await res.json().catch(() => null)) as { ticket?: unknown } | null
if (!body || typeof body.ticket !== "string" || !body.ticket) {
throw new WebSocketConnectError("Malformed event-service ticket response", 0)
}
return body.ticket
} catch (err) {
if (err instanceof WebSocketAuthError || err instanceof WebSocketConnectError) throw err
if ((err as { name?: string })?.name === "AbortError") {
throw new HandshakeTimeoutError()
}
throw new WebSocketConnectError(`Event-service ticket request failed: ${(err as Error)?.message ?? err}`, 0)
} finally {
clearTimeout(timer)
}
}
private clearHandshakeTimer(): void {
if (this.handshakeTimer !== null) {
clearTimeout(this.handshakeTimer)
this.handshakeTimer = null
}
}
private sendJson(msg: unknown): void {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(msg))
}
}
private handleMessage(data: string): void {
if (data === "pong") return
let parsed: unknown
try {
parsed = JSON.parse(data)
} catch {
return
}
if (!parsed || typeof parsed !== "object") return
const m = parsed as Record<string, unknown>
if (m.type === "event" && typeof m.context === "string" && typeof m.event === "string") {
const handlers = this.eventHandlers.get(m.event)
if (handlers) {
for (const h of handlers) h(m.context, m.payload)
}
return
}
if (m.type === "error") {
console.warn("[Kilo] event-service server error", m)
}
}
private startPing(): void {
this.stopPing()
this.pingTimer = setInterval(() => {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send("ping")
}
}, PING_INTERVAL_MS)
}
private stopPing(): void {
if (this.pingTimer !== null) {
clearInterval(this.pingTimer)
this.pingTimer = null
}
}
private resubscribeContexts(): void {
if (this.activeContexts.size > 0) {
this.sendJson({
type: "context.subscribe",
contexts: Array.from(this.activeContexts),
})
}
}
private scheduleReconnect(): void {
if (this.reconnectTimer !== null) return
const base = Math.min(30_000, 1000 * 2 ** this.reconnectAttempts)
const delay = base * (0.5 + Math.random() * 0.5)
this.reconnectAttempts++
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null
this.connectOnce().catch((err) => {
if (this.handleAuthFailure(err)) return
if (!this.destroyed) this.scheduleReconnect()
})
}, delay)
}
}
+186 -55
View File
@@ -1,24 +1,38 @@
// kilocode_change - new file
/**
* KiloClaw SolidJS reactive helpers
* KiloClaw SolidJS reactive helpers — Kilo Chat protocol.
*
* Provides reactive state management for polling instance status
* and managing the Stream Chat connection lifecycle.
* Exposes:
* - `createClawStatus`: polled instance status
* - `createClawChat`: Kilo Chat connection + active-conversation state
*
* `createClawChat` returns a reactive surface the view binds to: messages,
* conversation list, active conversation id + status (token counter), bot
* presence, and imperative actions (send / new / select / rename / delete).
*/
import { createSignal, onMount, onCleanup } from "solid-js"
import type { ClawStatus, ChatCredentials, ChatMessage } from "./types"
import { botId, connect, history, presence, type ClawChatClient } from "./client"
import type {
ChatMessage,
ChatToken,
ClawStatus,
ConversationListItem,
ConversationStatusRecord,
TypingMember,
} from "./types"
import { connect, type ClawChatClient } from "./client"
import { useSDK } from "@tui/context/sdk"
import * as Log from "@opencode-ai/core/util/log"
const log = Log.create({ service: "claw-chat" })
type SDK = ReturnType<typeof useSDK>
/**
* Poll the KiloClaw instance status every `interval` ms.
* Returns a reactive signal with the latest status.
*/
export function createClawStatus(sdk: any, interval = 10_000) {
export function createClawStatus(sdk: SDK, interval = 10_000) {
const [status, setStatus] = createSignal<ClawStatus | null>(null)
const [error, setError] = createSignal<string | null>(null)
const [loading, setLoading] = createSignal(true)
@@ -30,7 +44,10 @@ export function createClawStatus(sdk: any, interval = 10_000) {
setStatus(res.data as ClawStatus)
setError(null)
} else if (res?.error) {
setError(typeof res.error === "string" ? res.error : (res.error?.error ?? "Unknown error"))
// Gateway error envelopes are either `{ error: "..." }` or a plain
// string; the SDK types the error body as `unknown`.
const err = res.error as string | { error?: string } | null
setError(typeof err === "string" ? err : (err?.error ?? "Unknown error"))
} else {
setError("Network error")
}
@@ -44,97 +61,189 @@ export function createClawStatus(sdk: any, interval = 10_000) {
return { status, error, loading }
}
/**
* Fetch Stream Chat credentials and manage the chat connection.
* Returns reactive signals for messages, bot presence, and connection state.
*/
export function createClawChat(sdk: any) {
export type ClawChat = ReturnType<typeof createClawChat>
export function createClawChat(sdk: SDK) {
const [messages, setMessages] = createSignal<ChatMessage[]>([])
const [online, setOnline] = createSignal(false)
const [connected, setConnected] = createSignal(false)
const [error, setError] = createSignal<string | null>(null)
const [loading, setLoading] = createSignal(true)
const [conversations, setConversations] = createSignal<ConversationListItem[]>([])
const [activeConversationId, setActiveConversationId] = createSignal<string | null>(null)
const [conversationStatus, setConversationStatus] = createSignal<ConversationStatusRecord | null>(null)
const [typingMembers, setTypingMembers] = createSignal<TypingMember[]>([])
const MAX_MESSAGES = 500
let chat: ClawChatClient | null = null
const errText = (err: unknown) => (err instanceof Error ? err.message : String(err))
const send = async (text: string): Promise<boolean> => {
if (!chat) return false
try {
await chat.send(text)
return true
} catch (err: any) {
log.error("send failed", { error: err?.message ?? String(err) })
} catch (err) {
log.error("send failed", { error: errText(err) })
setError("Failed to send message")
return false
}
}
const newConversation = async (title?: string): Promise<string | null> => {
if (!chat) return null
try {
const id = await chat.createConversation(title)
await selectConversation(id)
return id
} catch (err) {
log.error("createConversation failed", { error: errText(err) })
setError("Failed to create conversation")
return null
}
}
const selectConversation = async (conversationId: string): Promise<boolean> => {
if (!chat) return false
try {
const result = await chat.selectConversation(conversationId)
setMessages(result.messages)
setConversationStatus(result.status)
return true
} catch (err) {
log.error("selectConversation failed", { error: errText(err) })
setError("Failed to load conversation")
return false
}
}
const renameConversation = async (conversationId: string, title: string): Promise<boolean> => {
if (!chat) return false
try {
await chat.renameConversation(conversationId, title)
return true
} catch (err) {
log.error("renameConversation failed", { error: errText(err) })
setError("Failed to rename conversation")
return false
}
}
const deleteConversation = async (conversationId: string): Promise<boolean> => {
if (!chat) return false
try {
await chat.deleteConversation(conversationId)
return true
} catch (err) {
log.error("deleteConversation failed", { error: errText(err) })
setError("Failed to delete conversation")
return false
}
}
const refreshConversations = async (): Promise<void> => {
if (!chat) return
try {
await chat.refreshConversations()
} catch (err) {
log.warn("refreshConversations failed", { error: errText(err) })
}
}
onMount(async () => {
// Register cleanup synchronously while the SolidJS owner scope is
// still available. After any `await` the owner is lost, so
// `onCleanup` calls would be silently discarded.
const cleanup = {
unsub: null as (() => void) | null,
unsubUpdated: null as (() => void) | null,
unsubPresence: null as (() => void) | null,
unsubConvs: null as (() => void) | null,
unsubActive: null as (() => void) | null,
unsubStatus: null as (() => void) | null,
unsubTyping: null as (() => void) | null,
}
onCleanup(() => {
cleanup.unsub?.()
cleanup.unsubUpdated?.()
cleanup.unsubPresence?.()
cleanup.unsubConvs?.()
cleanup.unsubActive?.()
cleanup.unsubStatus?.()
cleanup.unsubTyping?.()
if (chat) {
chat.disconnect().catch((err) => {
log.error("disconnect failed", { error: err?.message ?? String(err) })
log.error("disconnect failed", { error: errText(err) })
})
}
chat = null
})
log.info("fetching credentials")
const res = await sdk.client.kilo.claw.chatCredentials().catch((e: any) => {
log.error("chatCredentials() threw", { error: e?.message ?? String(e) })
return null
})
log.info("credentials response", {
hasData: String(!!res?.data),
dataIsNull: String(res?.data === null),
error: res?.error ? String(res.error) : "none",
dataKeys: res?.data ? Object.keys(res.data).join(",") : "none",
})
if (!res?.data || res.error) {
setError(res?.data === null ? null : "Failed to fetch chat credentials")
log.info("fetching status + credentials")
const statusRes = await sdk.client.kilo.claw.status().catch(() => null)
const statusData = statusRes?.data as (ClawStatus & { userId?: string; sandboxId?: string }) | undefined
if (!statusRes || statusRes.error || !statusData?.userId || !statusData?.sandboxId) {
setError(null)
setLoading(false)
return
}
const creds = res.data as ChatCredentials
const credsRes = await sdk.client.kilo.claw.chatCredentials().catch((e: unknown) => {
log.error("chatCredentials() threw", { error: errText(e) })
return null
})
if (!credsRes?.data || credsRes.error) {
setError(credsRes?.data === null ? null : "Failed to fetch chat credentials")
setLoading(false)
return
}
const envelope = credsRes.data as ChatToken
const missing: string[] = []
if (!envelope.token) missing.push("token")
if (!envelope.kiloChatUrl) missing.push("kiloChatUrl")
if (!envelope.eventServiceUrl) missing.push("eventServiceUrl")
if (missing.length > 0) {
setError(`Malformed chat credentials response: missing ${missing.join(", ")}`)
setLoading(false)
return
}
try {
log.info("calling connect()")
chat = await connect(creds)
log.info("connect() succeeded")
log.info("connecting kilo-chat")
chat = await connect({
envelope,
sandboxId: statusData.sandboxId,
currentUserId: statusData.userId,
})
log.info("connected")
// Load existing messages
const bot = botId(creds)
const existing = await history(chat.channel, bot)
log.info("loaded history", { count: existing.length })
setMessages(existing)
setConversations(chat.listConversations())
setOnline(chat.initialBotOnline())
// Read initial bot presence from channel member state
setOnline(presence(chat.channel, bot))
cleanup.unsubConvs = chat.onConversations((list) => {
setConversations(list)
})
cleanup.unsubActive = chat.onActiveConversation((id) => {
setActiveConversationId(id)
})
cleanup.unsubStatus = chat.onConversationStatus((status) => {
setConversationStatus(status)
})
cleanup.unsubTyping = chat.onTypingMembers((members) => {
setTypingMembers(members)
})
// Subscribe to new messages
cleanup.unsub = chat.onMessage((msg) => {
setMessages((prev) => {
if (prev.some((m) => m.id === msg.id)) return prev
const next = [...prev, msg]
return next.length > MAX_MESSAGES ? next.slice(-MAX_MESSAGES) : next
})
})
// Subscribe to message updates (bot streams token-by-token via message.updated)
cleanup.unsubUpdated = chat.onMessageUpdated((msg) => {
setMessages((prev) => {
const idx = prev.findIndex((m) => m.id === msg.id)
@@ -148,22 +257,44 @@ export function createClawChat(sdk: any) {
})
})
// Subscribe to bot presence changes
cleanup.unsubPresence = chat.onPresence(setOnline)
// Auto-open the most recent conversation, if any.
const list = chat.listConversations()
if (list.length > 0) {
await selectConversation(list[0]!.conversationId)
}
setConnected(true)
setLoading(false)
} catch (err: any) {
} catch (err) {
const e = err as { message?: string; name?: string; code?: string; stack?: string }
log.error("connect failed", {
error: err?.message ?? String(err),
name: err?.name,
code: err?.code,
stack: err?.stack?.split("\n").slice(0, 5).join("\n"),
error: errText(err),
name: e?.name,
code: e?.code,
stack: e?.stack?.split("\n").slice(0, 5).join("\n"),
})
setError(err?.message ?? "Failed to connect to chat")
setError(e?.message ?? "Failed to connect to chat")
setLoading(false)
}
})
return { messages, online, connected, error, loading, send }
return {
messages,
online,
connected,
error,
loading,
send,
conversations,
activeConversationId,
conversationStatus,
typingMembers,
newConversation,
selectConversation,
renameConversation,
deleteConversation,
refreshConversations,
}
}
@@ -0,0 +1,261 @@
// kilocode_change - new file
/**
* HTTP client for the kilo-chat Cloudflare Worker.
*
* Minimal inline port of `@kilocode/kilo-chat/client` (cloud monorepo)
* tailored to what the TUI needs. The kilo-chat worker validates payloads
* at its edge so we don't run zod here.
*/
import type {
BotStatusRecord,
ContentBlock,
ConversationListItem,
ConversationStatusRecord,
ExecApprovalDecision,
Message,
} from "./types"
export type KiloChatClientConfig = {
baseUrl: string
getToken: () => Promise<string>
onUnauthorized?: () => void
}
export class KiloChatApiError extends Error {
constructor(
public readonly status: number,
public readonly body: unknown,
) {
super(`KiloChat request failed: ${status}${formatBodyDetail(body)}`)
this.name = "KiloChatApiError"
}
}
function formatBodyDetail(body: unknown): string {
if (body === null || body === undefined) return ""
if (typeof body === "string") return ` - ${body}`
if (typeof body === "object") {
const err = (body as Record<string, unknown>).error
if (typeof err === "string") return ` - ${err}`
try {
return ` - ${JSON.stringify(body)}`
} catch {
return ""
}
}
return ""
}
type HttpOpts = {
method?: string
body?: unknown
query?: Record<string, string | number | boolean | undefined | null>
}
type SendQueue = Map<string, Promise<unknown>>
export class KiloChatClient {
private readonly baseUrl: string
private readonly getToken: () => Promise<string>
private readonly onUnauthorized: (() => void) | undefined
private readonly sendQueues: SendQueue = new Map()
constructor(config: KiloChatClientConfig) {
this.baseUrl = config.baseUrl.replace(/\/$/, "")
this.getToken = config.getToken
this.onUnauthorized = config.onUnauthorized
}
// ── Conversations ────────────────────────────────────────────────
listConversations(opts?: { sandboxId?: string; limit?: number; cursor?: string | null }): Promise<{
conversations: ConversationListItem[]
hasMore: boolean
nextCursor: string | null
}> {
return this.request("/v1/conversations", {
query: {
sandboxId: opts?.sandboxId,
limit: opts?.limit,
cursor: opts?.cursor ?? undefined,
},
})
}
createConversation(req: {
sandboxId: string
title?: string
}): Promise<{ conversationId: string; conversation?: unknown }> {
return this.request("/v1/conversations", { method: "POST", body: req })
}
renameConversation(conversationId: string, title: string): Promise<{ ok: true }> {
return this.request(`/v1/conversations/${conversationId}`, {
method: "PATCH",
body: { title },
})
}
async leaveConversation(conversationId: string): Promise<void> {
// Returns 200 JSON with `{ ok }`-style payload; we don't need the body.
await this.request<unknown>(`/v1/conversations/${conversationId}/leave`, { method: "POST" })
}
/**
* Mark messages up to `lastSeenMessageId` as read. The server enforces
* monotonic `lastReadAt` and reports whether it advanced plus whether
* the badge bucket was cleared.
*/
markConversationRead(
conversationId: string,
req: { lastSeenMessageId: string },
): Promise<{ ok: boolean; applied: boolean; lastReadAt: number; badgeClear: boolean }> {
return this.request(`/v1/conversations/${conversationId}/mark-read`, {
method: "POST",
body: req,
})
}
// ── Messages ─────────────────────────────────────────────────────
sendMessage(req: {
conversationId: string
content: ContentBlock[]
inReplyToMessageId?: string
clientId?: string
}): Promise<{ messageId: string; clientId?: string; message?: Message }> {
const prev = this.sendQueues.get(req.conversationId) ?? Promise.resolve()
const send = () =>
this.request<{ messageId: string; clientId?: string; message?: Message }>("/v1/messages", {
method: "POST",
body: req,
})
const next = prev.then(send, send)
this.sendQueues.set(req.conversationId, next)
const cleanup = () => {
if (this.sendQueues.get(req.conversationId) === next) {
this.sendQueues.delete(req.conversationId)
}
}
void next.then(cleanup, cleanup)
return next
}
editMessage(
messageId: string,
req: { conversationId: string; content: ContentBlock[]; timestamp: number },
): Promise<{ messageId?: string; message?: Message }> {
return this.request(`/v1/messages/${messageId}`, { method: "PATCH", body: req })
}
async deleteMessage(messageId: string, conversationId: string): Promise<void> {
// Returns 200 JSON with `{ ok }`-style payload; we don't need the body.
await this.request<unknown>(`/v1/messages/${messageId}`, {
method: "DELETE",
query: { conversationId },
})
}
listMessages(
conversationId: string,
opts?: { before?: string; limit?: number },
): Promise<{ messages: Message[]; hasMore: boolean; nextCursor: string | null }> {
return this.request(`/v1/conversations/${conversationId}/messages`, {
query: { before: opts?.before, limit: opts?.limit },
})
}
executeAction(
conversationId: string,
messageId: string,
req: { groupId: string; value: ExecApprovalDecision },
): Promise<{ ok?: boolean; message?: Message; content?: ContentBlock[] }> {
return this.request(`/v1/conversations/${conversationId}/messages/${messageId}/execute-action`, {
method: "POST",
body: req,
})
}
// ── Reactions ────────────────────────────────────────────────────
addReaction(
messageId: string,
req: { conversationId: string; emoji: string },
): Promise<{ id: string; operationId?: string }> {
return this.request(`/v1/messages/${messageId}/reactions`, { method: "POST", body: req })
}
async removeReaction(
messageId: string,
req: { conversationId: string; emoji: string },
): Promise<{ removed: boolean; id: string | null; operationId?: string }> {
return this.request<{ removed: boolean; id: string | null; operationId?: string }>(
`/v1/messages/${messageId}/reactions`,
{
method: "DELETE",
query: req,
},
)
}
// ── Typing ───────────────────────────────────────────────────────
async sendTyping(conversationId: string): Promise<void> {
await this.request<unknown>(`/v1/conversations/${conversationId}/typing`, { method: "POST" })
}
async sendTypingStop(conversationId: string): Promise<void> {
await this.request<unknown>(`/v1/conversations/${conversationId}/typing/stop`, { method: "POST" })
}
// ── Bot / conversation status ────────────────────────────────────
getBotStatus(sandboxId: string): Promise<{ status: BotStatusRecord | null }> {
return this.request(`/v1/sandboxes/${sandboxId}/bot-status`)
}
async requestBotStatus(sandboxId: string): Promise<void> {
await this.request<unknown>(`/v1/sandboxes/${sandboxId}/request-bot-status`, { method: "POST" })
}
getConversationStatus(conversationId: string): Promise<{ status: ConversationStatusRecord | null }> {
return this.request(`/v1/conversations/${conversationId}/conversation-status`)
}
// ── private ──────────────────────────────────────────────────────
private async request<T>(path: string, opts: HttpOpts = {}): Promise<T> {
const token = await this.getToken()
let url = `${this.baseUrl}${path}`
if (opts.query) {
const params = new URLSearchParams()
for (const [k, v] of Object.entries(opts.query)) {
if (v === undefined || v === null) continue
params.set(k, String(v))
}
const qs = params.toString()
if (qs) url += `?${qs}`
}
const headers: Record<string, string> = { Authorization: `Bearer ${token}` }
if (opts.body !== undefined) headers["Content-Type"] = "application/json"
const res = await fetch(url, {
method: opts.method ?? "GET",
headers,
body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
})
if (!res.ok) {
if (res.status === 401 || res.status === 403) this.onUnauthorized?.()
const body: unknown = await res.json().catch(() => null)
throw new KiloChatApiError(res.status, body)
}
if (res.status === 204) return undefined as unknown as T
return (await res.json()) as T
}
}
+107 -41
View File
@@ -3,15 +3,14 @@
/**
* KiloClaw status sidebar
*
* Displays essential instance information: status, bot presence,
* region, sandbox ID, uptime, and version.
*
* Visual style mirrors the session sidebar (routes/session/sidebar.tsx).
* Mirrors the session sidebar (routes/session/sidebar.tsx): bold
* conversation title at the top, then context-window usage, then
* instance/bot/details sections.
*/
import { Show } from "solid-js"
import { useTheme } from "@tui/context/theme"
import type { ClawStatus } from "./types"
import type { ClawStatus, ConversationListItem, ConversationStatusRecord } from "./types"
function dot(status: string | null | undefined, theme: any): string {
if (!status) return theme.textMuted
@@ -35,6 +34,12 @@ function uptime(started: string | null | undefined): string {
return `${seconds}s`
}
function formatTokens(n: number): string {
if (n < 1000) return String(n)
if (n < 1_000_000) return `${(n / 1000).toFixed(n < 10_000 ? 1 : 0)}k`
return `${(n / 1_000_000).toFixed(1)}M`
}
export function ClawSidebar(props: {
status: ClawStatus | null
loading: boolean
@@ -43,9 +48,19 @@ export function ClawSidebar(props: {
connected: boolean
chatLoading: boolean
chatError: string | null
conversations: ConversationListItem[]
activeConversationId: string | null
conversationStatus: ConversationStatusRecord | null
}) {
const { theme } = useTheme()
const conversationTitle = () => {
const id = props.activeConversationId
if (!id) return "New conversation"
const conv = props.conversations.find((c) => c.conversationId === id)
return conv?.title ?? "Untitled"
}
return (
<box
backgroundColor={theme.backgroundPanel}
@@ -58,11 +73,81 @@ export function ClawSidebar(props: {
>
<scrollbox flexGrow={1}>
<box flexShrink={0} gap={1} paddingRight={1}>
<box paddingRight={1}>
<text fg={theme.text}>
<b>KiloClaw</b>
</text>
</box>
{/* Conversation title (top, like session title) */}
<Show when={props.connected}>
<box paddingRight={1}>
<text fg={theme.text} wrapMode="word">
<b>{conversationTitle()}</b>
</text>
</box>
</Show>
{/* Bot status — same section pattern as Instance/Details */}
<Show when={props.connected || props.chatLoading || props.chatError}>
<box>
<text fg={theme.text}>
<b>Bot Status</b>
</text>
<Show when={props.chatError}>
<text fg={theme.error}>{props.chatError}</text>
</Show>
<Show when={!props.chatError && props.chatLoading}>
<text fg={theme.textMuted}>Connecting...</text>
</Show>
<Show when={!props.chatError && !props.chatLoading && props.connected}>
<box flexDirection="row" gap={1}>
<text flexShrink={0} style={{ fg: props.online ? theme.success : theme.textMuted }}>
•
</text>
<text fg={theme.text}>{props.online ? "Online" : "Offline"}</text>
</box>
</Show>
<Show when={!props.chatError && !props.chatLoading && !props.connected}>
<text fg={theme.textMuted}>Unavailable</text>
</Show>
</box>
</Show>
{/* Context window usage */}
<Show when={props.connected && props.conversationStatus}>
{(s) => (
<box>
<text fg={theme.text}>
<b>Context</b>
</text>
<Show when={s().contextWindow > 0}>
<box flexDirection="row" justifyContent="space-between">
<text fg={theme.textMuted}>Used</text>
<text fg={theme.text}>
{Math.min(100, Math.round((s().contextTokens / s().contextWindow) * 100))}%
</text>
</box>
</Show>
<box flexDirection="row" justifyContent="space-between">
<text fg={theme.textMuted}>Tokens</text>
<text fg={theme.text}>
{formatTokens(s().contextTokens)} / {formatTokens(s().contextWindow)}
</text>
</box>
<Show when={s().model}>
<box flexDirection="row" justifyContent="space-between">
<text fg={theme.textMuted}>Model</text>
<text fg={theme.text} wrapMode="none">
{s().model}
</text>
</box>
</Show>
<Show when={s().provider}>
<box flexDirection="row" justifyContent="space-between">
<text fg={theme.textMuted}>Provider</text>
<text fg={theme.text} wrapMode="none">
{s().provider}
</text>
</box>
</Show>
</box>
)}
</Show>
<Show when={props.loading}>
<text fg={theme.textMuted}>Loading...</text>
@@ -90,42 +175,23 @@ export function ClawSidebar(props: {
</box>
</box>
<box>
<text fg={theme.text}>
<b>Bot</b>
</text>
<Show when={!props.chatLoading} fallback={<text fg={theme.textMuted}>Connecting...</text>}>
<Show when={props.chatError}>
<text fg={theme.error}>{props.chatError}</text>
</Show>
<Show when={!props.chatError && props.connected}>
<box flexDirection="row" gap={1}>
<text flexShrink={0} style={{ fg: props.online ? theme.success : theme.textMuted }}>
•
</text>
<text fg={theme.text}>{props.online ? "Online" : "Offline"}</text>
</box>
</Show>
<Show when={!props.chatError && !props.connected}>
<text fg={theme.textMuted}>Unavailable</text>
</Show>
</Show>
</box>
<box>
<text fg={theme.text}>
<b>Details</b>
</text>
<text fg={theme.textMuted}>
Region <span style={{ fg: theme.text }}>{props.status!.flyRegion?.toUpperCase() ?? "—"}</span>
</text>
<text fg={theme.textMuted}>
Version <span style={{ fg: theme.text }}>{props.status!.openclawVersion ?? "—"}</span>
</text>
<box flexDirection="row" justifyContent="space-between">
<text fg={theme.textMuted}>Region</text>
<text fg={theme.text}>{props.status!.flyRegion?.toUpperCase() ?? "—"}</text>
</box>
<box flexDirection="row" justifyContent="space-between">
<text fg={theme.textMuted}>Version</text>
<text fg={theme.text}>{props.status!.openclawVersion ?? "—"}</text>
</box>
<Show when={props.status!.channelCount != null && props.status!.channelCount >= 1}>
<text fg={theme.textMuted}>
Channels <span style={{ fg: theme.text }}>{props.status!.channelCount}</span>
</text>
<box flexDirection="row" justifyContent="space-between">
<text fg={theme.textMuted}>Channels</text>
<text fg={theme.text}>{props.status!.channelCount}</text>
</box>
</Show>
</box>
</Show>
+188 -8
View File
@@ -1,13 +1,26 @@
// kilocode_change - new file
/**
* KiloClaw TUI types
* KiloClaw TUI types — Kilo Chat protocol.
*
* Types for the KiloClaw chat and dashboard feature in the TUI.
* Mirrors `@kilocode/kilo-chat` from the cloud monorepo. We carry our own
* copy here so the CLI can be built without an external dependency.
*/
export type ClawStatus = {
status: "provisioned" | "starting" | "restarting" | "running" | "stopped" | "destroying" | null
// `recovering` and `restoring` are transitional states the worker reports
// while bringing an instance back from an unexpected stop or snapshot
// restore (cloud: `services/kiloclaw/src/index.ts`).
status:
| "provisioned"
| "starting"
| "restarting"
| "recovering"
| "running"
| "stopped"
| "destroying"
| "restoring"
| null
sandboxId?: string
flyRegion?: string
machineSize?: { cpus: number; memory_mb: number }
@@ -17,15 +30,182 @@ export type ClawStatus = {
channelCount?: number
secretCount?: number
userId?: string
// User-chosen name for the KiloClaw bot, set during onboarding via the
// `patchBotIdentity` mutation. May be null for fresh instances that
// skipped the bot-identity step. The chat UI should fall back to the
// literal string "KiloClaw" when null.
botName?: string | null
}
export type ChatCredentials = {
apiKey: string
userId: string
userToken: string
channelId: string
// ── Kilo Chat token envelope (gateway response) ─────────────────────
export type ChatToken = {
token: string
expiresAt: string // ISO timestamp
kiloChatUrl: string
eventServiceUrl: string
}
// ── Content blocks ──────────────────────────────────────────────────
export type ExecApprovalDecision = "allow-once" | "allow-always" | "deny"
export type TextBlock = { type: "text"; text: string }
export type ActionItem = {
label: string
style: "primary" | "danger" | "secondary"
value: ExecApprovalDecision
}
export type ActionsBlock = {
type: "actions"
groupId: string
actions: ActionItem[]
resolved?: {
value: ExecApprovalDecision
resolvedBy: string
resolvedAt: number
}
}
export type ContentBlock = TextBlock | ActionsBlock
// ── Reactions ───────────────────────────────────────────────────────
export type ReactionSummary = {
emoji: string
count: number
memberIds: string[]
}
// ── Messages ────────────────────────────────────────────────────────
export type Message = {
id: string
senderId: string
content: ContentBlock[]
inReplyToMessageId: string | null
updatedAt: number | null
clientUpdatedAt: number | null
deleted: boolean
deliveryFailed: boolean
reactions: ReactionSummary[]
}
// ── Conversations ───────────────────────────────────────────────────
export type ConversationListItem = {
conversationId: string
title: string | null
lastActivityAt: number | null
lastReadAt: number | null
joinedAt: number
}
// ── Bot / conversation status ───────────────────────────────────────
export type BotStatusRecord = {
online: boolean
at: number
updatedAt: number
}
export type ConversationStatusRecord = {
conversationId: string
contextTokens: number
contextWindow: number
model: string | null
provider: string | null
at: number
updatedAt: number
}
// ── Events ──────────────────────────────────────────────────────────
/**
* Snapshot of the message that was replied to. Server includes this on
* `message.created` so clients can render a reply preview without a follow-up
* fetch. `deleted` mirrors the soft-deletion state at the time of replying.
*/
export type ReplyToSnapshot = {
messageId: string
senderId: string
content: ContentBlock[]
deleted?: boolean
}
export type MessageCreatedEvent = {
messageId: string
senderId: string
content: ContentBlock[]
inReplyToMessageId: string | null
clientId?: string
replyTo?: ReplyToSnapshot | null
}
export type MessageUpdatedEvent = {
messageId: string
content: ContentBlock[]
clientUpdatedAt: number | null
}
export type MessageDeletedEvent = { messageId: string }
export type MessageDeliveryFailedEvent = { messageId: string }
export type TypingEvent = { memberId: string }
export type TypingMember = { memberId: string; at: number }
export type ReactionAddedEvent = { messageId: string; memberId: string; emoji: string; operationId?: string }
export type ReactionRemovedEvent = { messageId: string; memberId: string; emoji: string; operationId?: string }
/**
* Server fans out the full conversation snapshot on `conversation.created` so
* clients can append to their list without a follow-up fetch.
*/
export type ConversationCreatedEvent = {
conversationId: string
conversation?: ConversationListItem
}
export type ConversationRenamedEvent = { conversationId: string; title: string }
export type ConversationLeftEvent = { conversationId: string }
export type ConversationActivityEvent = { conversationId: string; lastActivityAt: number }
export type ActionDeliveryFailedEvent = { conversationId: string; messageId: string; groupId: string }
export type BotStatusEvent = { sandboxId: string; online: boolean; at: number }
export type ConversationStatusEvent = {
conversationId: string
contextTokens: number
contextWindow: number
model: string | null
provider: string | null
at: number
}
export type KiloChatEventMap = {
"message.created": MessageCreatedEvent
"message.updated": MessageUpdatedEvent
"message.deleted": MessageDeletedEvent
"message.delivery_failed": MessageDeliveryFailedEvent
typing: TypingEvent
"typing.stop": TypingEvent
"reaction.added": ReactionAddedEvent
"reaction.removed": ReactionRemovedEvent
"conversation.created": ConversationCreatedEvent
"conversation.renamed": ConversationRenamedEvent
"conversation.left": ConversationLeftEvent
"conversation.activity": ConversationActivityEvent
"action.delivery_failed": ActionDeliveryFailedEvent
"bot.status": BotStatusEvent
"conversation.status": ConversationStatusEvent
}
export type KiloChatEventName = keyof KiloChatEventMap
// ── Legacy display message (for CLI rendering) ──────────────────────
/**
* Lightweight chat-message shape used by the existing CLI rendering layer.
* We keep this for backwards compatibility with components that already
* render text + bot flag. New code should prefer `Message`.
*/
export type ChatMessage = {
id: string
text: string
+80 -13
View File
@@ -12,16 +12,20 @@ import { createMemo } from "solid-js"
import { useKeyboard } from "@opentui/solid"
import { useRoute } from "@tui/context/route"
import { useSDK } from "@tui/context/sdk"
import { useCommandDialog } from "@tui/component/dialog-command"
import { useDialog } from "@tui/ui/dialog"
import { useCommandDialog, type CommandOption } from "@tui/component/dialog-command"
import { Toast } from "@tui/ui/toast"
import { ClawChat } from "./chat"
import { ClawSidebar } from "./sidebar"
import { createClawStatus, createClawChat } from "./hooks"
import { DialogConversationList } from "./dialog-conversation-list"
import type { ClawSlashOption } from "./autocomplete"
export function KiloClawView() {
const route = useRoute()
const sdk = useSDK()
const command = useCommandDialog()
const dialog = useDialog()
// Poll instance status
const { status, error: statusError, loading: statusLoading } = createClawStatus(sdk)
@@ -35,6 +39,12 @@ export function KiloClawView() {
return !s || s.status !== "running"
})
// Bot display name — sourced from the KiloClaw platform status (set by the
// user during onboarding via patchBotIdentity). Falls back to the literal
// "KiloClaw" while loading or for instances that skipped onboarding,
// matching the web UI's fallback chain.
const botName = createMemo(() => status()?.botName ?? "KiloClaw")
// Register escape to navigate back
useKeyboard((evt) => {
if (evt.name === "escape") {
@@ -44,19 +54,70 @@ export function KiloClawView() {
}
})
// Register view-specific commands
command.register(() => [
{
value: "kiloclaw.back",
title: "Back",
category: "KiloClaw",
hidden: true,
keybind: "escape" as any,
onSelect: () => {
route.back()
// KiloClaw view commands — single source of truth for both the global
// command palette / keybinds and the in-chat slash autocomplete.
// The list is reactive on `chat.connected` so the slash menu only
// exposes `/new` and `/conversations` once we're connected to kilo-chat.
const kiloCommands = createMemo<CommandOption[]>(() => {
const ready = chat.connected()
return [
{
value: "kiloclaw.back",
title: "Back",
description: "Return to the previous view",
category: "KiloClaw",
slash: { name: "back" },
keybind: "escape" as any,
onSelect: () => {
dialog.clear()
route.back()
},
},
},
])
{
value: "kiloclaw.new",
title: "New conversation",
description: "Start a new KiloClaw conversation",
category: "KiloClaw",
slash: { name: "new" },
enabled: ready,
hidden: !ready,
onSelect: async () => {
dialog.clear()
await chat.newConversation()
},
},
{
value: "kiloclaw.conversations",
title: "Conversations",
description: "Browse, rename, and delete KiloClaw conversations",
category: "KiloClaw",
slash: { name: "conversations", aliases: ["chats"] },
enabled: ready,
hidden: !ready,
onSelect: () => {
dialog.replace(() => <DialogConversationList chat={chat} />)
},
},
]
})
command.register(() => kiloCommands())
// Slashes for the in-chat autocomplete — derived from the same list so
// renames flow through automatically. We pad displays to a common width
// so the descriptions line up like in the main prompt's autocomplete.
const clawSlashes = createMemo<ClawSlashOption[]>(() => {
const visible = kiloCommands().filter((c) => c.enabled !== false && !c.hidden && c.slash)
const items = visible.map((c) => ({
display: "/" + c.slash!.name,
description: c.description ?? c.title,
aliases: c.slash!.aliases?.map((a) => "/" + a),
onSelect: () => c.onSelect?.(dialog),
}))
const max = items.reduce((m, i) => Math.max(m, i.display.length), 0)
if (!max) return items
return items.map((i) => ({ ...i, display: i.display.padEnd(max + 2) }))
})
return (
<box flexDirection="row" flexGrow={1} paddingLeft={2} gap={1}>
@@ -68,6 +129,9 @@ export function KiloClawView() {
loading={chat.loading()}
error={chat.error()}
disabled={disabled()}
typingMembers={chat.typingMembers()}
slashes={clawSlashes}
botName={botName()}
onSend={chat.send}
/>
<Toast />
@@ -80,6 +144,9 @@ export function KiloClawView() {
connected={chat.connected()}
chatLoading={chat.loading()}
chatError={chat.error()}
conversations={chat.conversations()}
activeConversationId={chat.activeConversationId()}
conversationStatus={chat.conversationStatus()}
/>
</box>
)
@@ -0,0 +1,58 @@
// kilocode_change - new file
/**
* Per-message thumbs up/down feedback for the TUI.
*
* Wired via the `messages_feedback_up` / `messages_feedback_down` keybinds
* in the Session route. Kept out of `routes/session/index.tsx` so the
* upstream-shared session route stays free of Kilo telemetry plumbing.
*/
import { Telemetry } from "@kilocode/kilo-telemetry"
import type { AssistantMessage, Message } from "@kilocode/sdk/v2"
import type { DialogContext } from "@tui/ui/dialog"
import type { ToastContext } from "@tui/ui/toast"
interface SessionRevert {
revert?: { messageID: string }
}
interface Context {
toast: ToastContext
session: () => SessionRevert | undefined
messages: () => Message[]
}
export function submitFeedback(rating: "up" | "down", dialog: DialogContext, ctx: Context): void {
if (!Telemetry.isEnabled()) {
ctx.toast.show({ message: "Feedback disabled: telemetry is off", variant: "info" })
dialog.clear()
return
}
const revertID = ctx.session()?.revert?.messageID
const lastAssistant = ctx.messages().findLast(
(msg): msg is AssistantMessage => msg.role === "assistant" && (!revertID || msg.id < revertID),
)
if (!lastAssistant) {
ctx.toast.show({ message: "No assistant messages found", variant: "error" })
dialog.clear()
return
}
const providerID = lastAssistant.providerID
const payload: Telemetry.FeedbackProperties = {
providerID,
modelID: lastAssistant.modelID,
rating,
}
const variant = (lastAssistant as AssistantMessage & { variant?: string }).variant
if (variant) payload.variant = variant
if (providerID === "kilo") {
payload.sessionID = lastAssistant.sessionID
payload.messageID = lastAssistant.id
payload.parentMessageID = lastAssistant.parentID
}
Telemetry.trackFeedback(payload)
ctx.toast.show({
message: rating === "up" ? "Thanks for the feedback!" : "Thanks — we'll use this to improve.",
variant: "success",
})
dialog.clear()
}
@@ -76,8 +76,15 @@ async function saveIndexing(
indexing: IndexingConfig,
toast: ReturnType<typeof useToast>,
): Promise<boolean> {
const response = await sdk.client.global.config.update({ config: { indexing } })
if (response.error) {
const global = { ...indexing }
delete global.enabled
const responses = await Promise.all([
...(Object.keys(global).length > 0 ? [sdk.client.global.config.update({ config: { indexing: global } })] : []),
...(indexing.enabled !== undefined
? [sdk.client.config.update({ config: { indexing: { enabled: indexing.enabled } } })]
: []),
])
if (responses.some((response) => response.error)) {
toast.show({ message: "Failed to save indexing config", variant: "error" })
return false
}
@@ -95,7 +95,8 @@ export namespace KilocodeConfig {
writable: (config: Config.Info) => Config.Info
}) {
const file = yield* projectConfigUpdateTarget(input)
const before = (yield* input.read(file)) ?? "{}"
const source = yield* input.read(file)
const before = source ?? "{}"
const patch = input.writable(input.config)
if (file.endsWith(".jsonc")) {
@@ -106,9 +107,25 @@ export namespace KilocodeConfig {
const existing = input.parse(before, file)
const merged = mergeConfig(input.writable(existing), patch)
if (source === undefined && Object.keys(merged).length === 0) return
yield* input.fs.writeWithDirs(file, JSON.stringify(merged, null, 2)).pipe(Effect.orDie)
})
export function scopeIndexing(info: Config.Info, scope: "global" | "local"): Config.Info {
if (scope !== "global") return info
return stripGlobalIndexing(info)
}
function stripGlobalIndexing(info: Config.Info): Config.Info {
// Indexing provider/storage settings can be global, but enablement is a per-project decision.
if (info.indexing?.enabled === undefined) return info
const indexing = Object.fromEntries(Object.entries(info.indexing).filter(([key]) => key !== "enabled"))
if (Object.keys(indexing).length > 0) return { ...info, indexing }
const copy = { ...info }
delete copy.indexing
return copy
}
// ── Warning helpers ──────────────────────────────────────────────────
/** Convert known config-loading error types into a Warning. Returns undefined for unknown errors. */
+141 -117
View File
@@ -1,7 +1,7 @@
import { readFile, writeFile, mkdir } from "fs/promises"
import { readFileSync } from "fs"
import { dirname } from "path"
import jschardet from "jschardet"
import chardet from "chardet"
import iconv from "iconv-lite"
/**
@@ -9,134 +9,158 @@ import iconv from "iconv-lite"
*
* Supported:
* - UTF-8 (with or without BOM)
* - UTF-16 LE/BE with BOM (detected by jschardet)
* - Legacy Latin and CJK encodings (detected by jschardet)
* - UTF-16 LE/BE with BOM (detected by chardet)
* - UTF-32 LE/BE with BOM (detected by chardet)
* - Legacy Latin and CJK encodings (detected by chardet)
*
* Not supported:
* - UTF-16 without BOM (ambiguous, rare)
* - UTF-32 (extremely rare)
* - UTF-16 or UTF-32 without BOM (ambiguous, rare)
*
* Detection strategy:
* 1. If the bytes are valid UTF-8, treat as UTF-8 (tracking the presence of a
* BOM so it can be written back).
* 2. Otherwise, trust jschardet.
* 2. Otherwise, trust chardet. chardet only reports the wide UTF variants
* when a BOM is present, which aligns with the contract above.
*
* iconv-lite's UTF codecs strip BOMs on decode and do not emit them on encode,
* so UTF BOMs are handled explicitly in {@link encode} to round-trip cleanly.
*
* Consumers should import this module as a namespace:
* import * as Encoding from "../kilocode/encoding"
*/
export namespace Encoding {
export const DEFAULT = "utf-8"
/**
* Synthetic label for UTF-8 files that start with a BOM. iconv-lite's utf-8
* codec always strips BOMs on decode and never emits one on encode, so we
* track the "with BOM" case explicitly to round-trip it faithfully.
*/
export const UTF8_BOM = "utf-8-bom"
const UTF8_BOM_BYTES = Buffer.from([0xef, 0xbb, 0xbf])
function hasUtf8Bom(bytes: Buffer): boolean {
return bytes.length >= 3 && bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf
export const DEFAULT = "utf-8"
/**
* Synthetic label for UTF-8 files that start with a BOM. iconv-lite's utf-8
* codec always strips BOMs on decode and never emits one on encode, so we
* track the "with BOM" case explicitly to round-trip it faithfully.
*/
export const UTF8_BOM = "utf-8-bom"
const BOMS = {
"utf-8-bom": Buffer.from([0xef, 0xbb, 0xbf]),
"utf-16le": Buffer.from([0xff, 0xfe]),
"utf-16be": Buffer.from([0xfe, 0xff]),
"utf-32le": Buffer.from([0xff, 0xfe, 0x00, 0x00]),
"utf-32be": Buffer.from([0x00, 0x00, 0xfe, 0xff]),
}
function startsWith(bytes: Buffer, bom: Buffer, limit: number): boolean {
return limit >= bom.length && bytes.subarray(0, bom.length).equals(bom)
}
function hasUtf8Bom(bytes: Buffer): boolean {
return startsWith(bytes, BOMS["utf-8-bom"], bytes.length)
}
/** True if `bytes[0..limit]` starts with a UTF-16 LE or BE byte-order mark. */
export function hasUtf16Bom(bytes: Buffer, limit = bytes.length): boolean {
// UTF-32 LE starts with FF FE 00 00, so exclude it to avoid misclassifying as UTF-16 LE.
if (hasUtf32Bom(bytes, limit)) return false
return startsWith(bytes, BOMS["utf-16le"], limit) || startsWith(bytes, BOMS["utf-16be"], limit)
}
/** True if `bytes[0..limit]` starts with a UTF-32 LE or BE byte-order mark. */
export function hasUtf32Bom(bytes: Buffer, limit = bytes.length): boolean {
return startsWith(bytes, BOMS["utf-32le"], limit) || startsWith(bytes, BOMS["utf-32be"], limit)
}
/**
* Canonicalize chardet labels to a stable lowercase-hyphenated form.
*
* iconv-lite already accepts every label chardet emits (e.g. "UTF-16 LE",
* "Shift_JIS", "KOI8-R"), so this map is not required to make decode/encode
* work. Its job is to give the rest of the codebase a consistent label —
* callers compare against `"utf-16le"`, `"windows-1251"`, etc., and should
* not have to account for chardet's casing or whitespace conventions.
*
* (ISO-2022-* is the one family iconv-lite does not support under any
* alias; those labels fall through to the `encodingExists` guard in
* `detect()` and are rejected to UTF-8.)
*/
function normalize(name: string): string {
const lower = name.toLowerCase().replace(/[^a-z0-9]/g, "")
const map: Record<string, string> = {
utf8: "utf-8",
utf16le: "utf-16le",
utf16be: "utf-16be",
utf32le: "utf-32le",
utf32be: "utf-32be",
iso88591: "iso-8859-1",
iso88592: "iso-8859-2",
iso88595: "iso-8859-5",
iso88597: "iso-8859-7",
iso88598: "iso-8859-8",
iso88599: "iso-8859-9",
windows1250: "windows-1250",
windows1251: "windows-1251",
windows1252: "windows-1252",
windows1253: "windows-1253",
windows1255: "windows-1255",
shiftjis: "Shift_JIS",
eucjp: "euc-jp",
iso2022jp: "iso-2022-jp",
euckr: "euc-kr",
iso2022kr: "iso-2022-kr",
big5: "big5",
gb18030: "gb18030",
koi8r: "koi8-r",
}
return map[lower] ?? name
}
/** True if `bytes[0..limit]` starts with a UTF-16 LE or BE byte-order mark. */
export function hasUtf16Bom(bytes: Buffer, limit = bytes.length): boolean {
if (limit < 2) return false
return (bytes[0] === 0xff && bytes[1] === 0xfe) || (bytes[0] === 0xfe && bytes[1] === 0xff)
}
/** Remap jschardet labels to iconv-lite compatible names. */
function normalize(name: string): string {
const lower = name.toLowerCase().replace(/[^a-z0-9]/g, "")
const map: Record<string, string> = {
utf8: "utf-8",
utf16le: "utf-16le",
utf16be: "utf-16be",
ascii: "utf-8",
iso88591: "iso-8859-1",
iso88592: "iso-8859-2",
iso88595: "iso-8859-5",
iso88597: "iso-8859-7",
iso88598: "iso-8859-8",
iso88599: "iso-8859-9",
windows1250: "windows-1250",
windows1251: "windows-1251",
windows1252: "windows-1252",
windows1253: "windows-1253",
windows1255: "windows-1255",
shiftjis: "Shift_JIS",
eucjp: "euc-jp",
iso2022jp: "iso-2022-jp",
euckr: "euc-kr",
iso2022kr: "iso-2022-kr",
big5: "big5",
gb2312: "gb2312",
gb18030: "gb18030",
koi8r: "koi8-r",
maccyrillic: "x-mac-cyrillic",
ibm855: "cp855",
ibm866: "cp866",
tis620: "tis-620",
}
return map[lower] ?? name
}
function isUtf8(bytes: Buffer): boolean {
try {
new TextDecoder("utf-8", { fatal: true }).decode(bytes)
return true
} catch {
return false
}
}
export function detect(bytes: Buffer): string {
if (bytes.length === 0) return DEFAULT
if (isUtf8(bytes)) return hasUtf8Bom(bytes) ? UTF8_BOM : DEFAULT
const result = jschardet.detect(bytes)
if (!result.encoding) return DEFAULT
const enc = normalize(result.encoding)
// Reject unsupported Unicode encodings (UTF-32 and anything iconv-lite cannot decode)
if (enc.toLowerCase().startsWith("utf-32")) return DEFAULT
if (!iconv.encodingExists(enc)) return DEFAULT
return enc
}
export function decode(bytes: Buffer, encoding: string): string {
if (encoding === UTF8_BOM) return iconv.decode(bytes, "utf-8")
return iconv.decode(bytes, encoding)
}
export function encode(text: string, encoding: string): Buffer {
// iconv-lite's UTF codecs strip/ignore BOMs, but we support "UTF-X with BOM"
// as a distinct variant. Prepend the BOM manually so round-tripping keeps
// the original byte signature intact. Strip a leading U+FEFF from `text`
// first so we never emit a double BOM when the decoded text already
// contains one (e.g. if a tool round-trips content verbatim).
const body = text.charCodeAt(0) === 0xfeff ? text.slice(1) : text
if (encoding === UTF8_BOM) return Buffer.concat([UTF8_BOM_BYTES, iconv.encode(body, "utf-8")])
const lower = encoding.toLowerCase()
if (lower === "utf-16le") return Buffer.concat([Buffer.from([0xff, 0xfe]), iconv.encode(body, encoding)])
if (lower === "utf-16be") return Buffer.concat([Buffer.from([0xfe, 0xff]), iconv.encode(body, encoding)])
return iconv.encode(text, encoding)
}
/** Read a file, detecting its encoding. */
export async function read(path: string): Promise<{ text: string; encoding: string }> {
const bytes = await readFile(path)
const encoding = detect(bytes)
return { text: decode(bytes, encoding), encoding }
}
/** Synchronous read, detecting encoding. */
export function readSync(path: string): { text: string; encoding: string } {
const bytes = readFileSync(path)
const encoding = detect(bytes)
return { text: decode(bytes, encoding), encoding }
}
/** Write text, ensuring parent directory exists, using the given encoding. */
export async function write(path: string, text: string, encoding: string = DEFAULT): Promise<void> {
await mkdir(dirname(path), { recursive: true })
await writeFile(path, encode(text, encoding))
function isUtf8(bytes: Buffer): boolean {
try {
new TextDecoder("utf-8", { fatal: true }).decode(bytes)
return true
} catch {
return false
}
}
export function detect(bytes: Buffer): string {
if (bytes.length === 0) return DEFAULT
if (isUtf8(bytes)) return hasUtf8Bom(bytes) ? UTF8_BOM : DEFAULT
const result = chardet.detect(bytes)
if (!result) return DEFAULT
const enc = normalize(result)
if (!iconv.encodingExists(enc)) return DEFAULT
return enc
}
export function decode(bytes: Buffer, encoding: string): string {
if (encoding === UTF8_BOM) return iconv.decode(bytes, "utf-8")
return iconv.decode(bytes, encoding)
}
export function encode(text: string, encoding: string): Buffer {
// iconv-lite's UTF codecs strip/ignore BOMs, but we support "UTF-X with BOM"
// as a distinct variant. Prepend the BOM manually so round-tripping keeps
// the original byte signature intact. Strip a leading U+FEFF from `text`
// first so we never emit a double BOM when the decoded text already
// contains one (e.g. if a tool round-trips content verbatim).
const body = text.charCodeAt(0) === 0xfeff ? text.slice(1) : text
const key = encoding === UTF8_BOM ? UTF8_BOM : encoding.toLowerCase()
const bom = BOMS[key as keyof typeof BOMS]
if (bom) return Buffer.concat([bom, iconv.encode(body, key === UTF8_BOM ? "utf-8" : key)])
return iconv.encode(text, encoding)
}
/** Read a file, detecting its encoding. */
export async function read(path: string): Promise<{ text: string; encoding: string }> {
const bytes = await readFile(path)
const encoding = detect(bytes)
return { text: decode(bytes, encoding), encoding }
}
/** Synchronous read, detecting encoding. */
export function readSync(path: string): { text: string; encoding: string } {
const bytes = readFileSync(path)
const encoding = detect(bytes)
return { text: decode(bytes, encoding), encoding }
}
/** Write text, ensuring parent directory exists, using the given encoding. */
export async function write(path: string, text: string, encoding: string = DEFAULT): Promise<void> {
await mkdir(dirname(path), { recursive: true })
await writeFile(path, encode(text, encoding))
}
+4 -1
View File
@@ -227,7 +227,10 @@ export async function generateCommandTable(options?: { commands?: Cmd[] }) {
})
}
const lines = ["| Command | Description |", "| --- | --- |"]
// The repo enforces unpadded markdown table separators via
// script/check-md-table-padding.ts (see AGENTS.md). Padded `| --- | --- |`
// separators fail CI, so emit the compact form here.
const lines = ["| Command | Description |", "|---|---|"]
for (const row of rows) {
lines.push(`| \`${row.display}\` | ${row.description} |`)
@@ -26,6 +26,8 @@ import { LanceDBRuntime } from "./lancedb" // kilocode_change
const log = Log.create({ service: "kilocode-indexing" })
const missing = () => disabledIndexingStatus("Indexing plugin is not enabled for this workspace.")
const noWorkspace = () =>
disabledIndexingStatus("Codebase indexing is disabled because no workspace folder is open in VS Code.")
function worktreeDisabled(): z.infer<typeof IndexingStatus> {
return {
@@ -200,6 +202,9 @@ export namespace KiloIndexing {
const boot = async (hit: Cache): Promise<Entry> => {
const dir = Instance.directory
const cfg = await Config.get()
if (process.env["KILO_DISABLE_CODEBASE_INDEXING"] === "vscode-no-workspace") {
return track(hit, await inert(() => noWorkspace()))
}
if (!hasIndexingPlugin(cfg.plugin)) {
return track(hit, await inert(() => missing()))
}
@@ -9,10 +9,6 @@ function rules(permission: string, ruleset?: Ruleset) {
export namespace ExternalDirectoryPermission {
export function evaluate(permission: string, pattern: string, ...sets: Array<Ruleset | undefined>) {
return evalRule(
permission,
pattern,
...sets.map((set) => rules(permission, set)),
)
return evalRule(permission, pattern, ...sets.map((set) => rules(permission, set)))
}
}
@@ -236,7 +236,7 @@ export namespace PlanFollowup {
// Fall back to plan file on disk
const session = await Session.get(SessionID.make(input.sessionID))
const file = Bun.file(Session.plan(session))
const file = Bun.file(Session.plan(session, Instance.current))
const plan = await file.text().catch(() => "")
return plan.trim()
}
@@ -351,7 +351,7 @@ export namespace PlanFollowup {
})
try {
const file = Session.plan(session)
const file = Session.plan(session, Instance.current)
const todos = await PlanFollowupRuntime.todo.get(input.sessionID)
const todoList = formatTodos(todos)
@@ -169,20 +169,6 @@ export function patchCustomLoaderResult(
if (!result.options) return
switch (providerID) {
case "anthropic": {
// Prepend claude-code beta flag to the anthropic-beta header
// TODO: Add adaptive thinking headers when @ai-sdk/anthropic supports it:
// adaptive-thinking-2026-01-28,effort-2025-11-24,max-effort-2026-01-24
const existing = result.options.headers?.["anthropic-beta"] ?? ""
const prefix = "claude-code-20250219"
if (!existing.includes(prefix)) {
result.options.headers = {
...result.options.headers,
"anthropic-beta": existing ? `${prefix},${existing}` : prefix,
}
}
break
}
case "openrouter":
case "vercel":
case "zenmux":
@@ -20,6 +20,7 @@ import { errors } from "../../server/error"
import { ModelCache } from "../../provider/model-cache"
import { Database } from "../../storage/db"
import { Instance } from "../../project/instance"
import { InstanceStore } from "../../project/instance-store"
import { Session } from "../../session/session"
import { Identifier } from "../../id/id"
import { SessionTable, MessageTable, PartTable } from "../../session/session.sql"
@@ -48,6 +49,7 @@ export function register(app: Hono): Hono {
z,
Database,
Instance,
InstanceStore,
SessionTable,
MessageTable,
PartTable,
@@ -3,7 +3,7 @@
// Imported by ../../server/server.ts with minimal kilocode_change markers.
import { ModelCache } from "../../provider/model-cache"
import { Instance } from "../../project/instance"
import { InstanceStore } from "../../project/instance-store"
/** Extra paths to skip request logging for */
export function skipLogging(path: string): boolean {
@@ -21,7 +21,7 @@ export function corsOrigin(input: string): string | undefined {
/** Invalidate model cache and provider state after auth change */
export async function authChanged(providerID: string) {
ModelCache.clear(providerID)
await Instance.disposeAll()
await InstanceStore.disposeAllInstances()
}
export const DOC_TITLE = "kilo"
@@ -8,12 +8,37 @@ import * as Log from "@opencode-ai/core/util/log"
import { Effect } from "effect"
import { Flag } from "@opencode-ai/core/flag/flag"
export type ReviewTelemetry = {
mode: "review"
feature: "code_reviews"
command: "local-review" | "local-review-uncommitted"
}
export namespace KiloSessionProcessor {
const log = Log.create({ service: "session.processor.kilo" })
export const OUTPUT_LENGTH_WARNING = "The model hit its output limit, so this response may be incomplete."
export const REASONING_LENGTH_WARNING =
"The model hit its output limit while reasoning and produced no actionable output. Try disabling reasoning or increasing the output limit."
export function reviewTelemetry(command: string): ReviewTelemetry | undefined {
if (command === "local-review" || command === "local-review-uncommitted") {
return { mode: "review", feature: "code_reviews", command }
}
}
export function extractReviewTelemetry(parts: MessageV2.Part[]): ReviewTelemetry | undefined {
for (const part of parts) {
if (part.type !== "text") continue
const meta: Record<string, unknown> | undefined = part.metadata
if (!meta) continue
if (meta.mode !== "review") continue
if (meta.feature !== "code_reviews") continue
const command = meta.command
if (command !== "local-review" && command !== "local-review-uncommitted") continue
return { mode: "review", feature: "code_reviews", command }
}
}
/**
* Track LLM completion telemetry for a finished step.
* Only fires if at least one token bucket is non-zero.
@@ -24,11 +49,13 @@ export namespace KiloSessionProcessor {
tokens: { input: number; output: number; cache: { read: number; write: number } }
cost: number
elapsed: number
telemetry?: ReviewTelemetry
}) {
const { tokens } = input
if (tokens.input > 0 || tokens.output > 0 || tokens.cache.write > 0 || tokens.cache.read > 0) {
Telemetry.trackLlmCompletion({
taskId: input.sessionID,
...(input.telemetry ?? {}),
apiProvider: input.model.providerID,
modelId: input.model.id,
inputTokens: tokens.input,
@@ -42,8 +42,8 @@ export namespace KiloSessionPromptQueue {
/**
* Exempt an injected user message from being hidden by scope().
* Called after PlanFollowup.inject() so the injected follow-up is visible
* without also unhiding unrelated prompts that were queued mid-turn.
* Called after internal follow-ups or compaction markers are persisted so
* they are visible without also unhiding unrelated prompts queued mid-turn.
*/
export function retarget(sessionID: SessionID, id: MessageID) {
const current = targets.get(sessionID)
@@ -2,10 +2,12 @@
import path from "path"
import fs from "fs/promises"
import { StringDecoder } from "string_decoder"
import { Cause, Exit } from "effect"
import { Cause, Effect, Exit } from "effect"
import { SessionID, PartID } from "@/session/schema"
import { MessageV2 } from "@/session/message-v2"
import { Session } from "@/session/session"
import { Instance } from "@/project/instance"
import type { SessionStatus } from "@/session/status"
import { Flag } from "@opencode-ai/core/flag/flag"
import { PlanFollowup } from "@/kilocode/plan-followup"
import { KiloSession } from "@/kilocode/session"
@@ -57,6 +59,26 @@ export namespace KiloSessionPrompt {
return PlanFollowup.abort(sessionID)
}
export const recoverDanglingAssistant = Effect.fn("KiloSessionPrompt.recoverDanglingAssistant")(function* (input: {
sessionID: SessionID
status: Pick<SessionStatus.Interface, "get">
sessions: Pick<Session.Interface, "messages" | "removeMessage">
}) {
const state = yield* input.status.get(input.sessionID)
if (state.type !== "idle") return
const msgs = yield* input.sessions.messages({ sessionID: input.sessionID, limit: 2 })
const tail = msgs.at(-1)
if (!tail || tail.info.role !== "assistant") return
if (tail.parts.length > 0 || tail.info.finish || tail.info.error) return
const prev = msgs.at(-2)
if (!prev || prev.info.role !== "user") return
if (tail.info.parentID !== prev.info.id) return
yield* input.sessions.removeMessage({ sessionID: input.sessionID, messageID: tail.info.id })
})
export function guardPermissions(input: {
agent: { name: string; permission: Permission.Ruleset }
session: Pick<Session.Info, "permission">
@@ -137,6 +159,16 @@ export namespace KiloSessionPrompt {
}
}
/**
* Ensures the plan file directory exists. Pre-checks with `Filesystem.isDir`
* because `fs.mkdir(recursive: true)` still throws `EEXIST` on Windows
* OneDrive ReparsePoint directories in some Node versions (kilocode#9755).
*/
export async function ensurePlanDir(dir: string) {
if (await Filesystem.isDir(dir)) return
await fs.mkdir(dir, { recursive: true })
}
/**
* Injects plan-specific reminders into the user message when using the plan agent.
* Ensures the plan file directory exists and tells the agent where to write.
@@ -147,9 +179,9 @@ export namespace KiloSessionPrompt {
userMessage: MessageV2.WithParts
}) {
if (input.agent.name !== "plan") return
const plan = Session.plan(input.session)
const plan = Session.plan(input.session, Instance.current)
const exists = await Filesystem.exists(plan)
if (!exists) await fs.mkdir(path.dirname(plan), { recursive: true })
if (!exists) await ensurePlanDir(path.dirname(plan))
const info = exists
? `A plan file already exists at ${plan}. You can read it and make incremental edits using the edit tool.`
: `No plan file exists yet. You should create your plan at ${plan} using the write tool.`
@@ -0,0 +1,7 @@
export namespace KiloSessionTuiSync {
export function model(input: { role: string; parts?: readonly { type: string }[] }) {
if (input.role !== "user") return false
if (!input.parts) return false
return !input.parts.some((part) => part.type === "compaction")
}
}
@@ -1,17 +1,19 @@
import { Effect } from "effect"
import { Encoding } from "../encoding"
import * as Encoding from "../encoding"
/**
* Effect wrappers around {@link Encoding.read} and {@link Encoding.write} so
* tool code can preserve file encoding without leaking Node/async boilerplate
* into each call site. Uses {@link Effect.tryPromise} so I/O failures surface
* as typed errors that can be recovered with `.pipe(Effect.catch(...))`.
*
* Consumers should import this module as a namespace:
* import * as EncodedIO from "../kilocode/tool/encoded-io"
*/
export namespace EncodedIO {
const wrap = (cause: unknown) => (cause instanceof Error ? cause : new Error(String(cause)))
export const read = (path: string) => Effect.tryPromise({ try: () => Encoding.read(path), catch: wrap })
const wrap = (cause: unknown) => (cause instanceof Error ? cause : new Error(String(cause)))
export const write = (path: string, text: string, encoding: string = Encoding.DEFAULT) =>
Effect.tryPromise({ try: () => Encoding.write(path, text, encoding), catch: wrap })
}
export const read = (path: string) => Effect.tryPromise({ try: () => Encoding.read(path), catch: wrap })
export const write = (path: string, text: string, encoding: string = Encoding.DEFAULT) =>
Effect.tryPromise({ try: () => Encoding.write(path, text, encoding), catch: wrap })
+7 -12
View File
@@ -12,9 +12,9 @@ import { Process } from "@/util/process"
import { spawn as lspspawn } from "./launch"
import { Effect, Layer, Context, Schema } from "effect"
import { InstanceState } from "@/effect/instance-state"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { containsPath } from "@/project/instance-context"
import { TsClient } from "../kilocode/ts-client" // kilocode_change
import { withStatics } from "@/util/schema"
import { NonNegativeInt, withStatics } from "@/util/schema"
import { zod, ZodOverride } from "@/util/effect-zod"
const log = Log.create({ service: "lsp" })
@@ -24,8 +24,8 @@ export const Event = {
}
const Position = Schema.Struct({
line: Schema.Number,
character: Schema.Number,
line: NonNegativeInt,
character: NonNegativeInt,
})
export const Range = Schema.Struct({
@@ -38,7 +38,7 @@ export type Range = typeof Range.Type
export const Symbol = Schema.Struct({
name: Schema.String,
kind: Schema.Number,
kind: NonNegativeInt,
location: Schema.Struct({
uri: Schema.String,
range: Range,
@@ -51,7 +51,7 @@ export type Symbol = typeof Symbol.Type
export const DocumentSymbol = Schema.Struct({
name: Schema.String,
detail: Schema.optional(Schema.String),
kind: Schema.Number,
kind: NonNegativeInt,
range: Range,
selectionRange: Range,
})
@@ -222,12 +222,7 @@ export const layer = Layer.effect(
const getClients = Effect.fnUntraced(function* (file: string) {
const ctx = yield* InstanceState.context
if (
!AppFileSystem.contains(ctx.directory, file) &&
(ctx.worktree === "/" || !AppFileSystem.contains(ctx.worktree, file))
) {
return [] as LSPClient.Info[]
}
if (!containsPath(file, ctx)) return [] as LSPClient.Info[]
const s = yield* InstanceState.get(state)
return yield* Effect.promise(async () => {
const extension = path.parse(file).ext || file
+25 -8
View File
@@ -138,6 +138,11 @@ function isMcpConfigured(entry: McpEntry): entry is ConfigMCP.Info {
const sanitize = (s: string) => s.replace(/[^a-zA-Z0-9_-]/g, "_")
function remoteURL(key: string, value: string) {
if (URL.canParse(value)) return new URL(value)
log.warn("invalid remote mcp url", { key })
}
// Convert MCP tool definition to AI SDK Tool type
function convertMcpTool(mcpTool: MCPToolDef, client: MCPClient, timeout?: number): Tool {
const inputSchema = mcpTool.inputSchema
@@ -275,7 +280,7 @@ export const layer = Layer.effect(
(t) =>
Effect.tryPromise({
try: () => {
const client = new Client({ name: "opencode", version: InstallationVersion })
const client = new Client({ name: "kilo", version: InstallationVersion }) // kilocode_change
return withTimeout(client.connect(t), timeout).then(() => client)
},
catch: (e) => (e instanceof Error ? e : new Error(String(e))),
@@ -291,6 +296,13 @@ export const layer = Layer.effect(
) {
const oauthDisabled = mcp.oauth === false
const oauthConfig = typeof mcp.oauth === "object" ? mcp.oauth : undefined
const url = remoteURL(key, mcp.url)
if (!url) {
return {
client: undefined as MCPClient | undefined,
status: { status: "failed" as const, error: `Invalid MCP URL for "${key}"` },
}
}
let authProvider: McpOAuthProvider | undefined
if (!oauthDisabled) {
@@ -315,14 +327,14 @@ export const layer = Layer.effect(
const transports: Array<{ name: string; transport: TransportWithAuth }> = [
{
name: "StreamableHTTP",
transport: new StreamableHTTPClientTransport(new URL(mcp.url), {
transport: new StreamableHTTPClientTransport(url, {
authProvider,
requestInit: mcp.headers ? { headers: mcp.headers } : undefined,
}),
},
{
name: "SSE",
transport: new SSEClientTransport(new URL(mcp.url), {
transport: new SSEClientTransport(url, {
authProvider,
requestInit: mcp.headers ? { headers: mcp.headers } : undefined,
}),
@@ -362,7 +374,7 @@ export const layer = Layer.effect(
return bus
.publish(TuiEvent.ToastShow, {
title: "MCP Authentication Required",
message: `Server "${key}" requires authentication. Run: opencode mcp auth ${key}`,
message: `Server "${key}" requires authentication. Run: kilo mcp auth ${key}`, // kilocode_change
variant: "warning",
duration: 8000,
})
@@ -742,11 +754,15 @@ export const layer = Layer.effect(
return mcpConfig
})
const startAuth = Effect.fn("MCP.startAuth")(function* (mcpName: string, opts?: { callback?: boolean }) { // kilocode_change
// kilocode_change start - `opts?: { callback?: boolean }` parameter is Kilo-specific
const startAuth = Effect.fn("MCP.startAuth")(function* (mcpName: string, opts?: { callback?: boolean }) {
// kilocode_change end
const mcpConfig = yield* getMcpConfig(mcpName)
if (!mcpConfig) throw new Error(`MCP server ${mcpName} not found or disabled`)
if (mcpConfig.type !== "remote") throw new Error(`MCP server ${mcpName} is not a remote server`)
if (mcpConfig.oauth === false) throw new Error(`MCP server ${mcpName} has OAuth explicitly disabled`)
const url = remoteURL(mcpName, mcpConfig.url)
if (!url) throw new Error(`Invalid MCP URL for "${mcpName}"`)
// OAuth config is optional - if not provided, we'll use auto-discovery
const oauthConfig = typeof mcpConfig.oauth === "object" ? mcpConfig.oauth : undefined
@@ -779,11 +795,11 @@ export const layer = Layer.effect(
auth,
)
const transport = new StreamableHTTPClientTransport(new URL(mcpConfig.url), { authProvider })
const transport = new StreamableHTTPClientTransport(url, { authProvider })
return yield* Effect.tryPromise({
try: () => {
const client = new Client({ name: "opencode", version: InstallationVersion })
const client = new Client({ name: "kilo", version: InstallationVersion }) // kilocode_change
return client
.connect(transport)
.then(() => ({ authorizationUrl: "", oauthState, client }) satisfies AuthResult)
@@ -826,7 +842,8 @@ export const layer = Layer.effect(
// kilocode_change start - bind only after redirect exists, and clean up if binding fails
const mcpConfig = yield* getMcpConfig(mcpName)
if (!mcpConfig) return { status: "failed", error: "MCP config not found after auth" } as Status
if (mcpConfig.type !== "remote") return { status: "failed", error: `MCP server ${mcpName} is not a remote server` } as Status
if (mcpConfig.type !== "remote")
return { status: "failed", error: `MCP server ${mcpName} is not a remote server` } as Status
const oauthConfig = typeof mcpConfig.oauth === "object" ? mcpConfig.oauth : undefined
const err = yield* Effect.tryPromise({
try: () => McpOAuthCallback.ensureRunning(oauthConfig?.redirectUri),
+2 -2
View File
@@ -44,8 +44,8 @@ export class McpOAuthProvider implements OAuthClientProvider {
get clientMetadata(): OAuthClientMetadata {
return {
redirect_uris: [this.redirectUrl],
client_name: "Kilo", // kilocode_change
client_uri: "https://kilo.ai", // kilocode_change
client_name: "Kilo",
client_uri: "https://kilo.ai",
grant_types: ["authorization_code", "refresh_token"],
response_types: ["code"],
token_endpoint_auth_method: this.config.clientSecret ? "client_secret_post" : "none",
+1 -1
View File
@@ -2,7 +2,7 @@ import z from "zod"
import * as path from "path"
import * as fs from "fs/promises"
import * as Log from "@opencode-ai/core/util/log"
import { Encoding } from "../kilocode/encoding" // kilocode_change
import * as Encoding from "../kilocode/encoding" // kilocode_change
import * as Bom from "../util/bom"
const log = Log.create({ service: "patch" })

Some files were not shown because too many files have changed in this diff Show More