fix: address third-pass upstream merge review

This commit is contained in:
Johnny Amancio
2026-07-16 00:00:06 +02:00
parent 790affb98f
commit e6d04275d3
46 changed files with 3240 additions and 226 deletions
+29 -16
View File
@@ -3,14 +3,14 @@ name: test
on:
push:
branches:
- main
- main # kilocode_change
pull_request:
workflow_dispatch:
concurrency:
# Keep every run on main so cancelled checks do not pollute the default branch
# Keep every run on main so cancelled checks do not pollute the default branch # kilocode_change
# commit history. PRs and other branches still share a group and cancel stale runs.
group: ${{ case(github.ref == 'refs/heads/main', format('{0}-{1}', github.workflow, github.run_id), format('{0}-{1}', github.workflow, github.event.pull_request.number || github.ref)) }}
group: ${{ case(github.ref == 'refs/heads/main', format('{0}-{1}', github.workflow, github.run_id), format('{0}-{1}', github.workflow, github.event.pull_request.number || github.ref)) }} # kilocode_change
cancel-in-progress: true
permissions:
@@ -65,26 +65,31 @@ jobs:
fi
echo 'general=true' >> "$GITHUB_OUTPUT"
echo 'settings=[{"os":"linux","index":1,"total":2,"host":"blacksmith-4vcpu-ubuntu-2404","run":true,"packages":true},{"os":"linux","index":2,"total":2,"host":"blacksmith-4vcpu-ubuntu-2404","run":true,"packages":false},{"os":"macos","index":1,"total":1,"host":"macos-15","run":true,"packages":true},{"os":"windows","index":1,"total":4,"host":"blacksmith-4vcpu-windows-2025","run":true,"packages":true},{"os":"windows","index":2,"total":4,"host":"blacksmith-4vcpu-windows-2025","run":true,"packages":false},{"os":"windows","index":3,"total":4,"host":"blacksmith-4vcpu-windows-2025","run":true,"packages":false},{"os":"windows","index":4,"total":4,"host":"blacksmith-4vcpu-windows-2025","run":true,"packages":false}]' >> "$GITHUB_OUTPUT"
# kilocode_change end
unit:
# kilocode_change start
name: ${{ !matrix.settings.run && 'unit (unchanged)' || matrix.settings.total > 1 && format('unit ({0}, {1}/{2})', matrix.settings.os, matrix.settings.index, matrix.settings.total) || format('unit ({0})', matrix.settings.os) }}
needs: changes
# kilocode_change end
strategy:
fail-fast: false
matrix:
settings: ${{ fromJSON(needs.changes.outputs.settings) }}
settings: ${{ fromJSON(needs.changes.outputs.settings) }} # kilocode_change
runs-on: ${{ matrix.settings.host }}
timeout-minutes: 45 # kilocode_change
defaults:
run:
shell: bash
steps:
# kilocode_change start
- name: Skip unchanged general unit tests
if: ${{ !matrix.settings.run }}
run: echo "Only isolated product, documentation, or metadata files changed; general unit tests are unchanged."
# kilocode_change end
- name: Checkout repository
if: matrix.settings.run
if: matrix.settings.run # kilocode_change
uses: actions/checkout@v6 # kilocode_change
with:
token: ${{ secrets.GITHUB_TOKEN }}
@@ -94,10 +99,12 @@ jobs:
if: matrix.settings.run
id: setup-node
continue-on-error: ${{ runner.os == 'Windows' }}
uses: actions/setup-node@v6 # kilocode_change
uses: actions/setup-node@v6
with:
node-version: "24"
# kilocode_change end
# kilocode_change start
- name: Retry Setup Node on Windows
if: matrix.settings.run && runner.os == 'Windows' && steps.setup-node.outcome == 'failure'
uses: actions/setup-node@v6
@@ -106,7 +113,7 @@ jobs:
# kilocode_change end
- name: Setup Bun
if: matrix.settings.run
if: matrix.settings.run # kilocode_change
uses: ./.github/actions/setup-bun
# kilocode_change start
@@ -115,33 +122,35 @@ jobs:
uses: ./.github/actions/setup-linux-sandbox
# kilocode_change end
- name: Configure git identity
if: matrix.settings.run
if: matrix.settings.run # kilocode_change
# kilocode_change start
run: |
git config --global user.email "kilo-maintainer[bot]@users.noreply.github.com"
git config --global user.name "kilo-maintainer[bot]"
# kilocode_change end
- name: Cache Turbo
if: matrix.settings.run
if: matrix.settings.run # kilocode_change
uses: actions/cache@v5 # kilocode_change
with:
path: .turbo/cache # kilocode_change
key: turbo-${{ runner.os }}-${{ hashFiles('turbo.json', 'bun.lock') }}-${{ matrix.settings.os }}-${{ matrix.settings.index }}-${{ github.sha }}
key: turbo-${{ runner.os }}-${{ hashFiles('turbo.json', 'bun.lock') }}-${{ matrix.settings.os }}-${{ matrix.settings.index }}-${{ github.sha }} # kilocode_change
# kilocode_change start
restore-keys: |
turbo-${{ runner.os }}-${{ hashFiles('turbo.json', 'bun.lock') }}-${{ matrix.settings.os }}-${{ matrix.settings.index }}-
turbo-${{ runner.os }}-${{ hashFiles('turbo.json', 'bun.lock') }}-
turbo-${{ runner.os }}-
# kilocode_change end
# kilocode_change start
- name: Run non-CLI unit tests
if: matrix.settings.run && matrix.settings.packages
# kilocode_change - every tested package exposes test:ci; Turbo must never silently select zero generic test tasks.
run: bun turbo test:ci --output-logs=errors-only --log-order=grouped --log-prefix=task --filter='!@kilocode/cli' --filter='!@kilocode/kilo-jetbrains'
# kilocode_change start - validate the Darwin profile independently before it can suppress its own tests
- name: Validate Darwin CLI test profile
if: matrix.settings.run && matrix.settings.os == 'macos'
working-directory: packages/opencode
run: bun test test/kilocode/test-profile.test.ts
# kilocode_change end
- name: Run CLI unit tests
if: matrix.settings.run
@@ -150,8 +159,10 @@ jobs:
KILO_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }}
KILO_TEST_PROFILE: ${{ matrix.settings.os == 'macos' && 'darwin' || '' }}
KILO_TEST_SHARD: ${{ format('{0}/{1}', matrix.settings.index, matrix.settings.total) }}
# kilocode_change end
- name: Publish unit reports # kilocode_change
# kilocode_change start
- name: Publish unit reports
if: always() && matrix.settings.run
uses: mikepenz/action-junit-report@bccf2e31636835cf0874589931c4116687171386 # v6.4.0
with:
@@ -163,14 +174,14 @@ jobs:
- name: Upload unit artifacts
if: always() && matrix.settings.run
uses: actions/upload-artifact@v7 # kilocode_change
uses: actions/upload-artifact@v7
with:
name: unit-${{ matrix.settings.os }}-${{ matrix.settings.index }}-${{ github.run_attempt }}
include-hidden-files: true
if-no-files-found: ignore
retention-days: 7
path: packages/*/.artifacts/unit/junit.xml
# kilocode_change end
# kilocode_change end
# kilocode_change start
httpapi:
@@ -233,7 +244,9 @@ jobs:
run: |
echo "unit=${{ needs.unit.result }}"
test "${{ needs.unit.result }}" = "success"
# kilocode_change end
# kilocode_change start
required:
name: test (linux)
runs-on: blacksmith-4vcpu-ubuntu-2404
+1 -2
View File
@@ -174,8 +174,7 @@ export const layer = Layer.effect(
const locationIsGlobal = path.resolve(location.directory) === path.resolve(global.config)
// Read configuration once when this location opens. Later calls reuse these
// values until the location is reopened.
// kilocode_change - keep Core V2 config discovery aligned with Kilo's isolated/project-disabled mode.
const discovered = locationIsGlobal || Flag.KILO_DISABLE_PROJECT_CONFIG
const discovered = locationIsGlobal || Flag.KILO_DISABLE_PROJECT_CONFIG // kilocode_change
? []
: yield* fs
.up({
+2 -1
View File
@@ -22,9 +22,10 @@ export const Plugin = {
for (const doc of (yield* config.entries()).filter(
(entry): entry is Config.Document => entry.type === "document",
)) {
// kilocode_change - Kilo local references are worktree-relative, falling back to the active directory outside a project.
// kilocode_change start
const root = path.parse(location.project.directory).root
const directory = location.project.directory === root ? location.directory : location.project.directory
// kilocode_change end
for (const [name, entry] of Object.entries(doc.info.references ?? {})) {
if (!validAlias(name)) continue
entries.set(
+7 -10
View File
@@ -323,15 +323,15 @@ export const locationLayer = Layer.effect(
const settle = Effect.fnUntraced(function* (
attemptID: AttemptID,
exit: Exit.Exit<Credential.Value, AuthorizationError>,
owned = false, // kilocode_change - completion may pre-claim settlement before awaiting its callback
owned = false,
) {
return yield* Effect.uninterruptibleMask((restore) =>
Effect.gen(function* () {
const pending = yield* SynchronizedRef.modify(attempts, (current) => {
const attempt = current.get(attemptID)
if (!attempt || attempt.status !== "pending") return [undefined, current] // kilocode_change
if (owned) return attempt.settling ? [attempt, current] : [undefined, current] // kilocode_change
if (attempt.settling) return [undefined, current] // kilocode_change
if (!attempt || attempt.status !== "pending") return [undefined, current]
if (owned) return attempt.settling ? [attempt, current] : [undefined, current]
if (attempt.settling) return [undefined, current]
return [attempt, new Map(current).set(attemptID, { ...attempt, settling: true })]
})
if (!pending) return
@@ -378,8 +378,7 @@ export const locationLayer = Layer.effect(
const next = new Map(current)
const scopes: Scope.Closeable[] = []
for (const [id, attempt] of current) {
// kilocode_change - settlement owns attempts once persistence starts
if (attempt.status === "pending" && !attempt.settling && attempt.time.expires <= now) {
if (attempt.status === "pending" && !attempt.settling && attempt.time.expires <= now) { // kilocode_change
scopes.push(attempt.scope)
next.set(id, { status: "expired", time: attempt.time, removeAt: now + terminalRetention })
continue
@@ -496,8 +495,7 @@ export const locationLayer = Layer.effect(
const match = current.get(input.attemptID)
if (!match || match.status !== "pending" || match.completing) return [match, current]
if (match.authorization.mode === "code" && input.code === undefined) return [match, current]
// kilocode_change - claim the attempt before awaiting the callback so cancel cannot delete it.
return [match, new Map(current).set(input.attemptID, { ...match, completing: true, settling: true })]
return [match, new Map(current).set(input.attemptID, { ...match, completing: true, settling: true })] // kilocode_change
})
if (!attempt) return yield* Effect.die(`OAuth attempt not found: ${input.attemptID}`)
if (attempt.status !== "pending") return
@@ -526,8 +524,7 @@ export const locationLayer = Layer.effect(
cancel: Effect.fn("Connector.connect.oauth.cancel")(function* (attemptID) {
const attempt = yield* SynchronizedRef.modify(attempts, (current) => {
const match = current.get(attemptID)
// kilocode_change - once persistence starts, settlement owns the attempt
if (!match || match.status !== "pending" || match.settling) return [undefined, current]
if (!match || match.status !== "pending" || match.settling) return [undefined, current] // kilocode_change
const next = new Map(current)
next.delete(attemptID)
return [match, next]
+12 -8
View File
@@ -1,7 +1,9 @@
export * as Credential from "./credential"
import { and, asc, desc, eq, ne } from "drizzle-orm" // kilocode_change
// kilocode_change start
import { and, asc, desc, eq, ne } from "drizzle-orm"
import { Context, Effect, Layer, Option, Schema, Semaphore } from "effect"
// kilocode_change end
import { Database } from "./database/database"
import { ConnectorSchema } from "./connector/schema"
import { EventV2 } from "./event"
@@ -248,8 +250,10 @@ export const layer = Layer.effect(
Effect.gen(function* () {
const { db } = yield* Database.Service
const events = yield* EventV2.Service
const fs = Option.getOrUndefined(yield* Effect.serviceOption(FSUtil.Service)) // kilocode_change
const global = Option.getOrUndefined(yield* Effect.serviceOption(Global.Service)) // kilocode_change
// kilocode_change start
const fs = Option.getOrUndefined(yield* Effect.serviceOption(FSUtil.Service))
const global = Option.getOrUndefined(yield* Effect.serviceOption(Global.Service))
// kilocode_change end
const decodeValue = Schema.decodeUnknownSync(Value)
const info = (row: typeof CredentialTable.$inferSelect) =>
new Info({
@@ -324,9 +328,7 @@ export const layer = Layer.effect(
const isolated = content !== undefined
const local = new Map([...injected.values()].map((credential) => [credential.id, credential]))
const selected = new Map([...injected].map(([connectorID, credential]) => [connectorID, credential.id]))
// kilocode_change end
// kilocode_change start - dual-write the active Core credential for released auth.json readers.
const lock = Semaphore.makeUnsafe(1)
const writeLegacy = (connectorID: ConnectorSchema.ID) =>
lock.withPermit(
@@ -530,8 +532,8 @@ export const layer = Layer.effect(
)
return
}
const row = yield* db.select().from(CredentialTable).where(eq(CredentialTable.id, id)).get().pipe(Effect.orDie)
// kilocode_change end
const row = yield* db.select().from(CredentialTable).where(eq(CredentialTable.id, id)).get().pipe(Effect.orDie) // kilocode_change
yield* db
.update(CredentialTable)
.set({ label: updates.label, value: updates.value })
@@ -604,8 +606,10 @@ export const layer = Layer.effect(
export const defaultLayer = layer.pipe(
Layer.provide(Database.defaultLayer),
Layer.provide(EventV2.defaultLayer),
Layer.provide(FSUtil.defaultLayer), // kilocode_change
Layer.provide(Global.defaultLayer), // kilocode_change
// kilocode_change start
Layer.provide(FSUtil.defaultLayer),
Layer.provide(Global.defaultLayer),
// kilocode_change end
Layer.provideMerge(
legacyImportLayer.pipe(
Layer.provide(Database.defaultLayer),
+35 -28
View File
@@ -1,6 +1,5 @@
export * as FileSystemSearch from "./search"
import os from "os" // kilocode_change
import path from "path"
import { Context, Effect, Layer, Scope } from "effect"
import { Fff } from "#fff"
@@ -11,7 +10,10 @@ import { Location } from "../location"
import { Ripgrep } from "../ripgrep"
import { RelativePath } from "../schema"
import { Flag } from "../flag/flag"
import * as SearchTarget from "../kilocode/search-target" // kilocode_change
// kilocode_change start
import * as SearchTarget from "../kilocode/search-target"
import { scanning } from "../kilocode/fff"
// kilocode_change end
export interface Interface {
readonly find: (input: FileSystem.FindInput) => Effect.Effect<FileSystem.Entry[]>
@@ -62,8 +64,10 @@ export const ripgrepLayer = Layer.effect(
return Service.of({
glob: (input) =>
Effect.gen(function* () {
const target = yield* inspect(input.path) // kilocode_change
const cwd = target.type === "file" ? path.dirname(target.path) : target.path // kilocode_change
// kilocode_change start
const target = yield* inspect(input.path)
const cwd = target.type === "file" ? path.dirname(target.path) : target.path
// kilocode_change end
return yield* ripgrep
.glob({
cwd,
@@ -73,8 +77,7 @@ export const ripgrepLayer = Layer.effect(
})
.pipe(
Effect.map((result) =>
result.items.map(
// kilocode_change
result.items.map( // kilocode_change
(entry) =>
new FileSystem.Entry({
...entry,
@@ -87,8 +90,10 @@ export const ripgrepLayer = Layer.effect(
}),
grep: (input) =>
Effect.gen(function* () {
const target = yield* inspect(input.path) // kilocode_change
const cwd = target.type === "file" ? path.dirname(target.path) : target.path // kilocode_change
// kilocode_change start
const target = yield* inspect(input.path)
const cwd = target.type === "file" ? path.dirname(target.path) : target.path
// kilocode_change end
return yield* ripgrep
.grep({
cwd,
@@ -100,8 +105,7 @@ export const ripgrepLayer = Layer.effect(
})
.pipe(
Effect.map((result) =>
result.items.map(
// kilocode_change
result.items.map( // kilocode_change
(match) =>
new FileSystem.Match({
...match,
@@ -117,7 +121,6 @@ export const ripgrepLayer = Layer.effect(
}),
find: (input) =>
Effect.gen(function* () {
// kilocode_change
const items =
input.type === "file"
? state.files
@@ -144,8 +147,8 @@ export const fffLayer = Layer.effect(
Service,
Effect.gen(function* () {
const location = yield* Location.Service
const fs = yield* FSUtil.Service // kilocode_change
// kilocode_change start - FFF is an index, not a security boundary; constrain its scan and filter canonical results.
// kilocode_change start
const fs = yield* FSUtil.Service
const inspect = Effect.fnUntraced(function* (input?: string) {
const root = yield* SearchTarget.inspect(fs, location.directory).pipe(Effect.orDie)
const requested = path.resolve(location.directory, input ?? ".")
@@ -168,10 +171,7 @@ export const fffLayer = Layer.effect(
Fff.create({
basePath: location.directory,
aiMode: true,
// kilocode_change start - permit broad scanning only when the Location is that exact boundary.
enableFsRootScanning: location.directory === path.parse(location.directory).root,
enableHomeDirScanning: location.directory === os.homedir(),
// kilocode_change end
...scanning(location.directory), // kilocode_change - permit broad scanning only at the exact boundary.
}),
catch: (cause) => cause,
}).pipe(Effect.orDie)
@@ -179,21 +179,25 @@ export const fffLayer = Layer.effect(
yield* Effect.addFinalizer(() => Effect.sync(() => result.value.destroy()).pipe(Effect.ignore))
return Service.of({
glob: (input) =>
// kilocode_change start
Effect.gen(function* () {
const { root, target } = yield* inspect(input.path) // kilocode_change
const { root, target } = yield* inspect(input.path)
// kilocode_change end
const prefix = input.path?.replaceAll("\\", "/").replace(/\/$/, "")
// kilocode_change start
const found = yield* Effect.sync(() =>
// kilocode_change
result.value.glob(prefix ? `${prefix}/${input.pattern}` : input.pattern, {
pageIndex: 0,
pageSize: input.limit,
}),
)
// kilocode_change end
if (!found.ok) throw found.error
yield* SearchTarget.validate(fs, target).pipe(Effect.orDie) // kilocode_change
const items = yield* Effect.filter(found.value.items, (item) => safe(root, item.relativePath)) // kilocode_change
// kilocode_change start
yield* SearchTarget.validate(fs, target).pipe(Effect.orDie)
const items = yield* Effect.filter(found.value.items, (item) => safe(root, item.relativePath))
return items.map((item) => {
// kilocode_change
// kilocode_change end
const absolute = path.resolve(location.directory, item.relativePath)
return new FileSystem.Entry({
path: RelativePath.make(item.relativePath.replaceAll("\\", "/")),
@@ -203,24 +207,27 @@ export const fffLayer = Layer.effect(
})
}),
grep: (input) =>
// kilocode_change start
Effect.gen(function* () {
// kilocode_change
const { root, target } = yield* inspect(input.path) // kilocode_change
const { root, target } = yield* inspect(input.path)
// kilocode_change end
const prefix = input.path?.replaceAll("\\", "/").replace(/\/$/, "")
// kilocode_change start
const found = yield* Effect.sync(() =>
// kilocode_change
result.value.grep(
[prefix ? `${prefix}/**` : undefined, input.include, input.pattern]
.filter((value) => value !== undefined)
.join(" "),
{ mode: "regex", pageSize: input.limit, timeBudgetMs: 1_500 },
),
// kilocode_change end
)
if (!found.ok) throw found.error
yield* SearchTarget.validate(fs, target).pipe(Effect.orDie) // kilocode_change
const items = yield* Effect.filter(found.value.items, (item) => safe(root, item.relativePath)) // kilocode_change
// kilocode_change start
yield* SearchTarget.validate(fs, target).pipe(Effect.orDie)
const items = yield* Effect.filter(found.value.items, (item) => safe(root, item.relativePath))
return items.map((match) => {
// kilocode_change
// kilocode_change end
const bytes = Buffer.from(match.lineContent)
return new FileSystem.Match({
entry: new FileSystem.Entry({
+34 -24
View File
@@ -1,11 +1,12 @@
import { Config } from "effect"
import { InstallationChannel } from "../installation/version"
import { InstallationChannel } from "../installation/version" // kilocode_change
export function truthy(key: string) {
const value = process.env[key]?.toLowerCase()
return value === "true" || value === "1"
}
// kilocode_change start
function falsy(key: string) {
const value = process.env[key]?.toLowerCase()
return value === "false" || value === "0"
@@ -26,6 +27,7 @@ function number(key: string) {
const KILO_EXPERIMENTAL = truthy("KILO_EXPERIMENTAL")
const KILO_DISABLE_CLAUDE_CODE = truthy("KILO_DISABLE_CLAUDE_CODE")
const KILO_DISABLE_CLAUDE_CODE_SKILLS = KILO_DISABLE_CLAUDE_CODE || truthy("KILO_DISABLE_CLAUDE_CODE_SKILLS")
// kilocode_change end
const copy = process.env["KILO_EXPERIMENTAL_DISABLE_COPY_ON_SELECT"]
const fff = process.env["KILO_DISABLE_FFF"]
@@ -37,7 +39,7 @@ export const Flag = {
OTEL_EXPORTER_OTLP_ENDPOINT: process.env["OTEL_EXPORTER_OTLP_ENDPOINT"],
OTEL_EXPORTER_OTLP_HEADERS: process.env["OTEL_EXPORTER_OTLP_HEADERS"],
KILO_AUTO_SHARE: truthy("KILO_AUTO_SHARE"),
KILO_AUTO_SHARE: truthy("KILO_AUTO_SHARE"), // kilocode_change
KILO_AUTO_HEAP_SNAPSHOT: truthy("KILO_AUTO_HEAP_SNAPSHOT"),
KILO_GIT_BASH_PATH: process.env["KILO_GIT_BASH_PATH"],
KILO_CONFIG: process.env["KILO_CONFIG"],
@@ -47,78 +49,84 @@ export const Flag = {
KILO_DISABLE_PRUNE: truthy("KILO_DISABLE_PRUNE"),
KILO_DISABLE_TERMINAL_TITLE: truthy("KILO_DISABLE_TERMINAL_TITLE"),
KILO_SHOW_TTFD: truthy("KILO_SHOW_TTFD"),
// kilocode_change start
KILO_DISABLE_DEFAULT_PLUGINS: truthy("KILO_DISABLE_DEFAULT_PLUGINS"),
KILO_DISABLE_LSP_DOWNLOAD: truthy("KILO_DISABLE_LSP_DOWNLOAD"),
KILO_ENABLE_EXPERIMENTAL_MODELS: truthy("KILO_ENABLE_EXPERIMENTAL_MODELS"),
// kilocode_change end
KILO_DISABLE_AUTOCOMPACT: truthy("KILO_DISABLE_AUTOCOMPACT"),
KILO_DISABLE_MODELS_FETCH: truthy("KILO_DISABLE_MODELS_FETCH"),
KILO_DISABLE_MOUSE: truthy("KILO_DISABLE_MOUSE"),
// kilocode_change start
KILO_DISABLE_CLAUDE_CODE,
KILO_DISABLE_CLAUDE_CODE_PROMPT: KILO_DISABLE_CLAUDE_CODE || truthy("KILO_DISABLE_CLAUDE_CODE_PROMPT"),
KILO_DISABLE_CLAUDE_CODE_SKILLS,
KILO_DISABLE_EXTERNAL_SKILLS: truthy("KILO_DISABLE_EXTERNAL_SKILLS"),
KILO_EXPERIMENTAL_CUSTOMIZE_SKILL: unstableDefault("KILO_EXPERIMENTAL_CUSTOMIZE_SKILL"), // kilocode_change
KILO_EXPERIMENTAL_CUSTOMIZE_SKILL: unstableDefault("KILO_EXPERIMENTAL_CUSTOMIZE_SKILL"),
// kilocode_change end
KILO_FAKE_VCS: process.env["KILO_FAKE_VCS"],
KILO_SERVER_PASSWORD: process.env["KILO_SERVER_PASSWORD"],
KILO_SERVER_USERNAME: process.env["KILO_SERVER_USERNAME"],
KILO_ENABLE_QUESTION_TOOL: truthy("KILO_ENABLE_QUESTION_TOOL"),
KILO_ENABLE_QUESTION_TOOL: truthy("KILO_ENABLE_QUESTION_TOOL"), // kilocode_change
KILO_EXPERIMENTAL,
KILO_EXPERIMENTAL, // kilocode_change
KILO_EXPERIMENTAL_FILEWATCHER: Config.boolean("KILO_EXPERIMENTAL_FILEWATCHER").pipe(Config.withDefault(false)),
KILO_EXPERIMENTAL_FILEWATCHER: Config.boolean("KILO_EXPERIMENTAL_FILEWATCHER").pipe(Config.withDefault(false)), // kilocode_change
KILO_EXPERIMENTAL_DISABLE_FILEWATCHER: Config.boolean("KILO_EXPERIMENTAL_DISABLE_FILEWATCHER").pipe(
Config.withDefault(false),
),
KILO_EXPERIMENTAL_ICON_DISCOVERY: KILO_EXPERIMENTAL || truthy("KILO_EXPERIMENTAL_ICON_DISCOVERY"),
KILO_EXPERIMENTAL_ICON_DISCOVERY: KILO_EXPERIMENTAL || truthy("KILO_EXPERIMENTAL_ICON_DISCOVERY"), // kilocode_change
KILO_EXPERIMENTAL_DISABLE_COPY_ON_SELECT:
copy === undefined ? process.platform === "win32" : truthy("KILO_EXPERIMENTAL_DISABLE_COPY_ON_SELECT"),
KILO_ENABLE_EXA: truthy("KILO_ENABLE_EXA") || KILO_EXPERIMENTAL || truthy("KILO_EXPERIMENTAL_EXA"),
KILO_ENABLE_EXA: truthy("KILO_ENABLE_EXA") || KILO_EXPERIMENTAL || truthy("KILO_EXPERIMENTAL_EXA"), // kilocode_change
KILO_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS: number("KILO_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS"),
KILO_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS: number("KILO_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS"), // kilocode_change
KILO_EXPERIMENTAL_OUTPUT_TOKEN_MAX: number("KILO_EXPERIMENTAL_OUTPUT_TOKEN_MAX"),
KILO_EXPERIMENTAL_OUTPUT_TOKEN_MAX: number("KILO_EXPERIMENTAL_OUTPUT_TOKEN_MAX"), // kilocode_change
KILO_EXPERIMENTAL_OXFMT: KILO_EXPERIMENTAL || truthy("KILO_EXPERIMENTAL_OXFMT"),
KILO_EXPERIMENTAL_OXFMT: KILO_EXPERIMENTAL || truthy("KILO_EXPERIMENTAL_OXFMT"), // kilocode_change
KILO_EXPERIMENTAL_LSP_TY: truthy("KILO_EXPERIMENTAL_LSP_TY"),
KILO_EXPERIMENTAL_LSP_TY: truthy("KILO_EXPERIMENTAL_LSP_TY"), // kilocode_change
KILO_EXPERIMENTAL_LSP_TOOL: KILO_EXPERIMENTAL || truthy("KILO_EXPERIMENTAL_LSP_TOOL"),
KILO_EXPERIMENTAL_LSP_TOOL: KILO_EXPERIMENTAL || truthy("KILO_EXPERIMENTAL_LSP_TOOL"), // kilocode_change
KILO_EXPERIMENTAL_PLAN_MODE: KILO_EXPERIMENTAL || truthy("KILO_EXPERIMENTAL_PLAN_MODE"),
KILO_EXPERIMENTAL_PLAN_MODE: KILO_EXPERIMENTAL || truthy("KILO_EXPERIMENTAL_PLAN_MODE"), // kilocode_change
KILO_EXPERIMENTAL_SCOUT: KILO_EXPERIMENTAL || truthy("KILO_EXPERIMENTAL_SCOUT"),
KILO_EXPERIMENTAL_SCOUT: KILO_EXPERIMENTAL || truthy("KILO_EXPERIMENTAL_SCOUT"), // kilocode_change
KILO_EXPERIMENTAL_MARKDOWN: !falsy("KILO_EXPERIMENTAL_MARKDOWN"),
KILO_EXPERIMENTAL_MARKDOWN: !falsy("KILO_EXPERIMENTAL_MARKDOWN"), // kilocode_change
KILO_ENABLE_PARALLEL: truthy("KILO_ENABLE_PARALLEL") || truthy("KILO_EXPERIMENTAL_PARALLEL"),
KILO_ENABLE_PARALLEL: truthy("KILO_ENABLE_PARALLEL") || truthy("KILO_EXPERIMENTAL_PARALLEL"), // kilocode_change
KILO_MODELS_URL: process.env["KILO_MODELS_URL"],
KILO_MODELS_PATH: process.env["KILO_MODELS_PATH"],
KILO_DISABLE_EMBEDDED_WEB_UI: truthy("KILO_DISABLE_EMBEDDED_WEB_UI"),
KILO_DISABLE_EMBEDDED_WEB_UI: truthy("KILO_DISABLE_EMBEDDED_WEB_UI"), // kilocode_change
KILO_DB: process.env["KILO_DB"],
KILO_DISABLE_CHANNEL_DB: truthy("KILO_DISABLE_CHANNEL_DB"),
KILO_DISABLE_CHANNEL_DB: truthy("KILO_DISABLE_CHANNEL_DB"), // kilocode_change
KILO_SKIP_MIGRATIONS: truthy("KILO_SKIP_MIGRATIONS"),
KILO_SKIP_MIGRATIONS: truthy("KILO_SKIP_MIGRATIONS"), // kilocode_change
KILO_STRICT_CONFIG_DEPS: truthy("KILO_STRICT_CONFIG_DEPS"),
KILO_STRICT_CONFIG_DEPS: truthy("KILO_STRICT_CONFIG_DEPS"), // kilocode_change
KILO_WORKSPACE_ID: process.env["KILO_WORKSPACE_ID"],
KILO_EXPERIMENTAL_WORKSPACES: enabledByExperimental("KILO_EXPERIMENTAL_WORKSPACES"),
KILO_EXPERIMENTAL_EVENT_SYSTEM: KILO_EXPERIMENTAL || truthy("KILO_EXPERIMENTAL_EVENT_SYSTEM"),
KILO_EXPERIMENTAL_EVENT_SYSTEM: KILO_EXPERIMENTAL || truthy("KILO_EXPERIMENTAL_EVENT_SYSTEM"), // kilocode_change
KILO_EXPERIMENTAL_SESSION_SWITCHING: KILO_EXPERIMENTAL || truthy("KILO_EXPERIMENTAL_SESSION_SWITCHING"),
KILO_EXPERIMENTAL_SESSION_SWITCHING: KILO_EXPERIMENTAL || truthy("KILO_EXPERIMENTAL_SESSION_SWITCHING"), // kilocode_change
KILO_DISABLE_FFF: fff === undefined ? process.platform === "win32" : truthy("KILO_DISABLE_FFF"),
KILO_EXPERIMENTAL_SESSION_SWITCHER: enabledByExperimental("KILO_EXPERIMENTAL_SESSION_SWITCHER"), // kilocode_change
KILO_DISABLE_FFF: fff === undefined ? process.platform === "win32" : truthy("KILO_DISABLE_FFF"), // kilocode_change
get KILO_DISABLE_PROJECT_CONFIG() {
return truthy("KILO_DISABLE_PROJECT_CONFIG")
@@ -144,7 +152,9 @@ export const Flag = {
get KILO_CLIENT() {
return process.env["KILO_CLIENT"] ?? "cli"
},
// kilocode_change start
get KILO_SESSION_RETRY_LIMIT() {
return number("KILO_SESSION_RETRY_LIMIT")
},
// kilocode_change end
}
+9
View File
@@ -0,0 +1,9 @@
import os from "os"
import path from "path"
export function scanning(directory: string) {
return {
enableFsRootScanning: directory === path.parse(directory).root,
enableHomeDirScanning: directory === os.homedir(),
}
}
@@ -10,7 +10,7 @@ function record(value: unknown): value is Record<string, unknown> {
export function normalize(value: unknown): unknown {
if (!record(value)) return value
// kilocode_change - new readers recover the canonical summary while old readers receive recent context inline.
// New readers recover the canonical summary while old readers receive recent context inline.
if (value.type === "compaction" && typeof value.kilo_summary === "string") {
return { ...value, summary: value.kilo_summary }
}
@@ -29,7 +29,7 @@ export function normalize(value: unknown): unknown {
export function encode(value: unknown): unknown {
if (!record(value)) return value
// kilocode_change start - preserve current semantics while making released compaction rows self-contained.
// Preserve current semantics while making released compaction rows self-contained.
if (value.type === "compaction" && typeof value.summary === "string" && typeof value.recent === "string") {
return {
...value,
@@ -37,7 +37,6 @@ export function encode(value: unknown): unknown {
kilo_summary: value.summary,
}
}
// kilocode_change end
if (value.type !== "assistant" || !Array.isArray(value.content)) return value
return {
...value,
+9 -3
View File
@@ -20,8 +20,10 @@ import type { DeepMutable } from "../schema"
type DatabaseService = Database.Interface["db"]
const decodeMessage = Schema.decodeUnknownSync(SessionMessage.Message)
// kilocode_change start
const encodeMessage = (message: SessionMessage.Message) =>
StoredMessage.encode(Schema.encodeSync(SessionMessage.Message)(message)) as (typeof SessionMessage.Message)["Encoded"] // kilocode_change
StoredMessage.encode(Schema.encodeSync(SessionMessage.Message)(message)) as (typeof SessionMessage.Message)["Encoded"]
// kilocode_change end
class PromptAlreadyProjected extends Error {}
export class SessionAlreadyProjected extends Error {}
@@ -141,11 +143,13 @@ function run(db: DatabaseService, event: SessionEvent.Event) {
.select()
.from(SessionMessageTable)
.where(
// kilocode_change start
and(
eq(SessionMessageTable.session_id, event.data.sessionID),
eq(SessionMessageTable.type, "assistant"),
isNotNull(SessionMessageTable.seq), // kilocode_change
isNotNull(SessionMessageTable.seq),
),
// kilocode_change end
)
.orderBy(desc(SessionMessageTable.seq))
.limit(1)
@@ -181,13 +185,15 @@ function run(db: DatabaseService, event: SessionEvent.Event) {
const rows = yield* db
.select()
.from(SessionMessageTable)
// kilocode_change start
.where(
and(
eq(SessionMessageTable.session_id, event.data.sessionID),
eq(SessionMessageTable.type, "shell"),
isNotNull(SessionMessageTable.seq), // kilocode_change
isNotNull(SessionMessageTable.seq),
),
)
// kilocode_change end
.orderBy(desc(SessionMessageTable.seq))
.all()
.pipe(Effect.orDie)
+12 -8
View File
@@ -4,8 +4,10 @@ import { ToolFailure } from "@opencode-ai/llm"
import { Effect, Layer, Schema } from "effect"
import path from "path"
import { FileSystem } from "../filesystem"
import { FSUtil } from "../fs-util" // kilocode_change
import * as SearchTarget from "../kilocode/search-target" // kilocode_change
// kilocode_change start
import { FSUtil } from "../fs-util"
import * as SearchTarget from "../kilocode/search-target"
// kilocode_change end
import { Location } from "../location"
import { Reference } from "../reference" // kilocode_change
import { Ripgrep } from "../ripgrep"
@@ -21,12 +23,14 @@ export const Input = Schema.Struct({
path: RelativePath.pipe(Schema.optional).annotate({
description: "Relative directory to search. Defaults to the active Location.",
}),
// kilocode_change start
reference: Schema.NonEmptyString.pipe(Schema.optional).annotate({
description: "Named project reference to search instead of the active Location",
}), // kilocode_change
}),
limit: FileSystem.SearchLimit.pipe(Schema.optional).annotate({
// kilocode_change end
description: "Maximum results to return",
}), // kilocode_change
}),
})
// kilocode_change start - retain bounded-search status in tool results and model output
@@ -109,8 +113,10 @@ export const layer = Layer.effectDiscard(
.glob({
cwd: target.path, // kilocode_change
pattern: input.pattern,
limit: input.limit ?? FileSystem.DEFAULT_SEARCH_LIMIT, // kilocode_change
validate: SearchTarget.validate(fs, target), // kilocode_change - reject post-approval replacement
// kilocode_change start
limit: input.limit ?? FileSystem.DEFAULT_SEARCH_LIMIT,
validate: SearchTarget.validate(fs, target),
// kilocode_change end
})
.pipe(
// kilocode_change start - preserve search status after canonical path mapping
@@ -119,7 +125,6 @@ export const layer = Layer.effectDiscard(
new Result({
...result,
items: result.items.map(
// kilocode_change start - report paths from the canonical validated target
(entry) =>
new FileSystem.Entry({
...entry,
@@ -127,7 +132,6 @@ export const layer = Layer.effectDiscard(
path.relative(location.directory, path.resolve(target.path, entry.path)),
),
}),
// kilocode_change end
),
}),
),
+15 -9
View File
@@ -5,8 +5,10 @@ import { Effect, Layer, Schema } from "effect"
import path from "path"
import { FileSystem } from "../filesystem"
import { FSUtil } from "../fs-util"
import { Global } from "../global" // kilocode_change
import * as SearchTarget from "../kilocode/search-target" // kilocode_change
// kilocode_change start
import { Global } from "../global"
import * as SearchTarget from "../kilocode/search-target"
// kilocode_change end
import { Location } from "../location"
import { Reference } from "../reference" // kilocode_change
import { PermissionV2 } from "../permission"
@@ -24,15 +26,17 @@ export const Input = Schema.Struct({
path: RelativePath.pipe(Schema.optional).annotate({
description: "Relative directory to search. Defaults to the active Location.",
}),
// kilocode_change start
reference: Schema.NonEmptyString.pipe(Schema.optional).annotate({
description: "Named project reference to search instead of the active Location",
}), // kilocode_change
}),
// kilocode_change end
include: FileSystem.GrepInput.fields.include.annotate({
description: 'File glob to include in the search (for example, "*.js" or "*.{ts,tsx}")',
}),
limit: FileSystem.SearchLimit.pipe(Schema.optional).annotate({
limit: FileSystem.SearchLimit.pipe(Schema.optional).annotate({ // kilocode_change
description: "Maximum matches to return",
}), // kilocode_change
}),
})
// kilocode_change start - retain bounded-search status in tool results and model output
@@ -127,16 +131,18 @@ export const layer = Layer.effectDiscard(
const retained = !ref && absolute && !contained && (yield* SearchTarget.managed(fs, global.data, target))
if (root.type !== "directory" || (!contained && !retained))
return yield* Effect.fail(new Error("Path escapes the active Location"))
const cwd = target.type === "directory" ? target.path : path.dirname(target.path)
// kilocode_change end
const cwd = target.type === "directory" ? target.path : path.dirname(target.path) // kilocode_change
return yield* ripgrep
.grep({
cwd, // kilocode_change
pattern: input.pattern,
file: target.type === "file" ? path.basename(target.path) : undefined, // kilocode_change
include: input.include,
limit: input.limit ?? FileSystem.DEFAULT_SEARCH_LIMIT, // kilocode_change
validate: SearchTarget.validate(fs, target), // kilocode_change - reject post-approval replacement
// kilocode_change start
limit: input.limit ?? FileSystem.DEFAULT_SEARCH_LIMIT,
validate: SearchTarget.validate(fs, target),
// kilocode_change end
})
.pipe(
// kilocode_change start - preserve search status after canonical path mapping
@@ -153,7 +159,7 @@ export const layer = Layer.effectDiscard(
path: RelativePath.make(
path.relative(
location.directory,
path.resolve(cwd, match.entry.path), // kilocode_change
path.resolve(cwd, match.entry.path),
),
),
}),
+2
View File
@@ -162,6 +162,7 @@ describe("Config", () => {
),
)
// kilocode_change start
it.live("skips project configuration when project discovery is disabled", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
@@ -198,6 +199,7 @@ describe("Config", () => {
),
),
)
// kilocode_change end
it.live("loads JSON and JSONC files from lowest to highest priority", () =>
Effect.acquireRelease(
+38 -3
View File
@@ -358,6 +358,7 @@ describe("Connector", () => {
}).pipe(Effect.provide(connectionLayer(created)))
})
// kilocode_change start
it.effect("fails auto OAuth when credential persistence fails", () => {
const failed = Connector.locationLayer.pipe(
Layer.provide(EventV2.defaultLayer),
@@ -396,7 +397,6 @@ describe("Connector", () => {
}).pipe(Effect.provide(failed))
})
// kilocode_change start - verify atomic OAuth persistence and cancellation
it.effect("fails code OAuth when credential persistence fails", () => {
const failed = Connector.locationLayer.pipe(
Layer.provide(EventV2.defaultLayer),
@@ -504,7 +504,6 @@ describe("Connector", () => {
}),
)
// kilocode_change start - cancellation must not delete a completing OAuth attempt
it.effect("keeps a code OAuth attempt while its callback is completing", () => {
const created: Array<{
connectorID: Connector.ID
@@ -549,7 +548,43 @@ describe("Connector", () => {
expect(yield* connectors.connect.oauth.status(attempt.attemptID)).toMatchObject({ status: "complete" })
}).pipe(Effect.provide(connectionLayer(created)))
})
// kilocode_change end
it.effect("fails and releases code OAuth attempts when the callback times out", () =>
Effect.gen(function* () {
const started = yield* Deferred.make<void>()
const state = { closed: false }
const connectors = yield* Connector.Service
const connectorID = Connector.ID.make("openai")
const methodID = Connector.MethodID.make("chatgpt")
yield* connectors.update((editor) =>
editor.method.update({
connectorID,
method: new Connector.OAuthMethod({ id: methodID, type: "oauth", label: "ChatGPT" }),
authorize: () =>
Effect.addFinalizer(() => Effect.sync(() => (state.closed = true))).pipe(
Effect.as({
mode: "code" as const,
url: "https://example.com/authorize",
instructions: "Paste the code",
callback: () => Deferred.succeed(started, undefined).pipe(Effect.andThen(Effect.never)),
}),
),
}),
)
const attempt = yield* connectors.connect.oauth.begin({ connectorID, methodID, inputs: {} })
const fiber = yield* connectors.connect.oauth
.complete({ attemptID: attempt.attemptID, code: "1234" })
.pipe(Effect.exit, Effect.forkScoped)
yield* Deferred.await(started)
yield* TestClock.adjust(Duration.seconds(30))
const exit = yield* Fiber.join(fiber)
expect(Exit.isFailure(exit)).toBe(true)
yield* Effect.yieldNow
expect(yield* connectors.connect.oauth.status(attempt.attemptID)).toMatchObject({ status: "failed" })
expect(state.closed).toBe(true)
}).pipe(Effect.provide(layer)),
)
it.effect("fails and releases OAuth attempts when credential persistence times out", () =>
Effect.gen(function* () {
+15 -2
View File
@@ -96,9 +96,8 @@ describe("Credential", () => {
),
)
// kilocode_change end
// kilocode_change - released auth.json remains authoritative when reconciling on startup
it.live("reconciles supported legacy auth.json credentials on startup", () =>
// kilocode_change end
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
@@ -226,6 +225,20 @@ describe("Credential", () => {
"legacy-reader": { type: "api", key: "first" },
})
const other = yield* service.create({
connectorID,
methodID: Connector.MethodID.make("api-key"),
value: new Credential.Key({ type: "key", key: "other" }),
})
expect(yield* Effect.promise(() => Bun.file(path.join(tmp.path, "auth.json")).json())).toMatchObject({
"legacy-reader": { type: "api", key: "other" },
})
yield* service.activate(created.id)
expect(yield* Effect.promise(() => Bun.file(path.join(tmp.path, "auth.json")).json())).toMatchObject({
"legacy-reader": { type: "api", key: "first" },
})
yield* service.remove(other.id)
yield* service.update(created.id, { value: new Credential.Key({ type: "key", key: "second" }) })
expect(yield* Effect.promise(() => Bun.file(path.join(tmp.path, "auth.json")).json())).toMatchObject({
"legacy-reader": { type: "api", key: "second" },
+27
View File
@@ -0,0 +1,27 @@
import { describe, expect, test } from "bun:test"
import os from "os"
import path from "path"
import { scanning } from "@opencode-ai/core/kilocode/fff"
describe("FFF scanning boundaries", () => {
test("enables filesystem-root scanning only at the exact root", () => {
const root = path.parse(process.cwd()).root
expect(scanning(root)).toEqual({ enableFsRootScanning: true, enableHomeDirScanning: root === os.homedir() })
expect(scanning(path.join(root, "workspace"))).toEqual({
enableFsRootScanning: false,
enableHomeDirScanning: false,
})
})
test("enables home scanning only at the exact home directory", () => {
const home = os.homedir()
expect(scanning(home)).toEqual({
enableFsRootScanning: home === path.parse(home).root,
enableHomeDirScanning: true,
})
expect(scanning(path.join(home, "workspace"))).toEqual({
enableFsRootScanning: false,
enableHomeDirScanning: false,
})
})
})
@@ -36,7 +36,7 @@ const references = (items: Reference.Info[] = []) =>
Reference.Service,
Reference.Service.of({
transform: () => Effect.die("unused"),
replace: () => Effect.die("unused"), // kilocode_change
replace: () => Effect.die("unused"),
list: () => Effect.succeed(items),
}),
)
+9 -5
View File
@@ -11,11 +11,12 @@ import { it } from "./lib/effect"
const cache = Layer.mock(RepositoryCache.Service, {
ensure: () => Effect.die("unexpected Git materialization"),
})
// kilocode_change - keep reference state tests independent from the persistent event store.
// kilocode_change start
const events = Layer.mock(EventV2.Service)({
publish: (definition, data) =>
Effect.succeed({ id: EventV2.ID.make("evt_reference_test"), type: definition.type, data }),
})
// kilocode_change end
describe("Reference", () => {
it.effect("registers normalized sources for the owning scope", () =>
@@ -41,7 +42,7 @@ describe("Reference", () => {
}).pipe(
Effect.provide(Reference.layer),
Effect.provide(cache),
Effect.provide(events),
Effect.provide(events), // kilocode_change
Effect.provide(Global.defaultLayer),
),
)
@@ -65,7 +66,7 @@ describe("Reference", () => {
Effect.scoped,
Effect.provide(Reference.layer),
Effect.provide(cache),
Effect.provide(events),
Effect.provide(events), // kilocode_change
Effect.provide(Global.defaultLayer),
),
)
@@ -94,12 +95,12 @@ describe("Reference", () => {
Effect.scoped,
Effect.provide(Reference.layer),
Effect.provide(cache),
// kilocode_change start
Effect.provide(events),
Effect.provide(Global.defaultLayer),
),
)
// kilocode_change - Kilo config reconciliation must clear stale sources without owning a scoped transform.
it.effect("replaces sources without a scoped transform", () =>
Effect.gen(function* () {
const references = yield* Reference.Service
@@ -107,9 +108,11 @@ describe("Reference", () => {
const stale = new Reference.LocalSource({ type: "local", path: AbsolutePath.make("/stale") })
const current = new Reference.LocalSource({ type: "local", path: AbsolutePath.make("/current") })
yield* update((editor) => editor.add("stale", stale))
// kilocode_change end
yield* references.replace([["current", current]])
yield* references.replace([["current", current]]) // kilocode_change
// kilocode_change start
expect(yield* references.list()).toEqual([
new Reference.Info({ name: "current", path: AbsolutePath.make("/current"), source: current }),
])
@@ -118,6 +121,7 @@ describe("Reference", () => {
Effect.provide(Reference.layer),
Effect.provide(cache),
Effect.provide(events),
// kilocode_change end
Effect.provide(Global.defaultLayer),
),
)
+96 -1
View File
@@ -201,6 +201,48 @@ describe("SessionProjector", () => {
timestamp: created,
model,
})
// kilocode_change start
const assistantID = SessionMessage.ID.create()
yield* events.publish(SessionEvent.Step.Started, {
sessionID,
assistantMessageID: assistantID,
timestamp: created,
agent: "build",
model,
})
yield* events.publish(SessionEvent.Tool.Input.Started, {
sessionID,
assistantMessageID: assistantID,
timestamp: created,
callID: "call-read",
name: "read",
})
yield* events.publish(SessionEvent.Tool.Input.Ended, {
sessionID,
assistantMessageID: assistantID,
timestamp: created,
callID: "call-read",
text: '{"path":"pixel.png"}',
})
yield* events.publish(SessionEvent.Tool.Called, {
sessionID,
assistantMessageID: assistantID,
timestamp: created,
callID: "call-read",
tool: "read",
input: { path: "pixel.png" },
provider: { executed: false },
})
yield* events.publish(SessionEvent.Tool.Success, {
sessionID,
assistantMessageID: assistantID,
timestamp: DateTime.makeUnsafe(1),
callID: "call-read",
structured: {},
content: [{ type: "file", uri: "data:image/png;base64,AAAA", mime: "image/png", name: "pixel.png" }],
provider: { executed: false },
})
// kilocode_change end
yield* events.publish(SessionEvent.Synthetic, {
sessionID,
messageID: SessionMessage.ID.create(),
@@ -265,15 +307,68 @@ describe("SessionProjector", () => {
.orderBy(asc(SessionMessageTable.seq))
.all()
.pipe(Effect.orDie)
// kilocode_change start - assert the projector itself writes the released-reader compaction shape.
const compaction = rows.find((row) => row.type === "compaction")
expect(compaction?.data).toMatchObject({
summary: "summary\n\nRecent context:\nrecent context",
kilo_summary: "summary",
recent: "recent context",
})
const released = Schema.decodeUnknownSync(
Schema.Struct({ type: Schema.Literal("compaction"), summary: Schema.String }),
)({ ...compaction?.data, type: compaction?.type })
expect(released.summary).toBe("summary\n\nRecent context:\nrecent context")
const assistant = rows.find((row) => row.id === assistantID)
expect(assistant?.data).toMatchObject({
content: [
{
type: "tool",
state: {
content: [
{
type: "file",
source: { type: "data", data: "AAAA" },
mime: "image/png",
name: "pixel.png",
},
],
},
},
],
})
Schema.decodeUnknownSync(
Schema.Struct({
type: Schema.Literal("assistant"),
content: Schema.Array(
Schema.Struct({
type: Schema.Literal("tool"),
state: Schema.Struct({
content: Schema.Array(
Schema.Struct({
type: Schema.Literal("file"),
source: Schema.Struct({ type: Schema.Literal("data"), data: Schema.String }),
mime: Schema.String,
name: Schema.String,
}),
),
}),
}),
),
}),
)({ ...assistant?.data, type: assistant?.type })
// kilocode_change end
const messages = rows.map((row) =>
// kilocode_change start
Schema.decodeUnknownSync(SessionMessage.Message)(
StoredMessage.normalize({ ...row.data, id: row.id, type: row.type }), // kilocode_change
StoredMessage.normalize({ ...row.data, id: row.id, type: row.type }),
),
// kilocode_change end
)
expect(messages.map((message) => message.type)).toEqual([
"agent-switched",
"model-switched",
"assistant", // kilocode_change
"synthetic",
"shell",
"compaction",
+4 -8
View File
@@ -54,10 +54,8 @@ export const ToolFileContent = Schema.Struct({
export type ToolFileContent = typeof ToolFileContent.Type
/** Ordered, provider-independent content shown to models and UIs after a tool succeeds. */
// kilocode_change start - keep the public schema canonical while storage accepts released shapes
export const ToolContent = Schema.Union([ToolTextContent, ToolFileContent]).pipe(Schema.toTaggedUnion("type"))
export type ToolContent = Schema.Schema.Type<typeof ToolContent>
// kilocode_change end
// kilocode_change start - decode persisted V2 tool file shapes and legacy media results
const LegacyToolFileContent = Schema.Struct({
@@ -78,7 +76,6 @@ const LegacyToolMediaContent = Schema.Struct({
})
const ToolContentInput = Schema.Union([ToolContent, LegacyToolFileContent, LegacyToolMediaContent])
// kilocode_change start - released readers require source-wrapped stored files
const stored = (item: ToolContent): typeof ToolContentInput.Type => {
if (item.type === "text") return item
const data = /^data:[^;,]+;base64,(.*)$/s.exec(item.uri)?.[1]
@@ -89,7 +86,6 @@ const stored = (item: ToolContent): typeof ToolContentInput.Type => {
: ({ type: "file", uri: item.uri } as const)
return { type: "file", source, mime: item.mime, name: item.name }
}
// kilocode_change end
export const StoredToolContent = ToolContentInput.pipe(
Schema.decodeTo(ToolContent, {
@@ -111,12 +107,10 @@ export const StoredToolContent = ToolContentInput.pipe(
: item.source.uri
return { type: "file" as const, uri, mime: item.mime, name: item.name }
}),
encode: SchemaGetter.transform(stored), // kilocode_change - released readers require the source-wrapped file shape
encode: SchemaGetter.transform(stored),
}),
)
// kilocode_change end
// kilocode_change start - avoid circular inference rejected by Kilo's newer tsgo
const toolResultValueSchema = Schema.Union([
Schema.Struct({ type: Schema.Literal("json"), value: Schema.Unknown }),
Schema.Struct({ type: Schema.Literal("text"), value: Schema.Unknown }),
@@ -131,14 +125,16 @@ const isToolResultValue = (value: unknown): value is ToolResultValue =>
(value.type === "text" || value.type === "json" || value.type === "error" || value.type === "content") &&
"value" in value
// kilocode_change start
export const ToolResultValue = Object.assign(toolResultValueSchema, {
is: isToolResultValue,
make: (value: unknown, type: ToolResultValue["type"] = "json"): ToolResultValue => {
if (isToolResultValue(value)) return value
if (type === "content") return { type, value: Array.isArray(value) ? value : [] }
return { type, value }
// kilocode_change end
},
})
}) // kilocode_change
export interface ToolOutput {
readonly structured: unknown
+42 -34
View File
@@ -27,11 +27,13 @@ import { InstanceState } from "@/effect/instance-state"
import * as Option from "effect/Option"
import * as OtelTracer from "@effect/opentelemetry/Tracer"
import { AbsolutePath, type DeepMutable } from "@opencode-ai/core/schema"
import * as KiloAgent from "@/kilocode/agent" // kilocode_change
// kilocode_change start
import * as KiloAgent from "@/kilocode/agent"
import { RuntimeFlags } from "@/effect/runtime-flags"
import * as AgentRequirements from "@/kilocode/agent-requirements" // kilocode_change
import * as KiloReference from "@/kilocode/reference" // kilocode_change
import { MCP } from "@/mcp" // kilocode_change
import * as AgentRequirements from "@/kilocode/agent-requirements"
import * as KiloReference from "@/kilocode/reference"
import { MCP } from "@/mcp"
// kilocode_change end
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
@@ -43,8 +45,10 @@ export type RequirementBlockedError = InstanceType<typeof AgentRequirements.Bloc
export const Info = Schema.Struct({
name: Schema.String,
displayName: Schema.optional(Schema.String), // kilocode_change - human-readable name for org modes
source: Schema.optional(Schema.String), // kilocode_change - origin marker (organization | global | project)
// kilocode_change start
displayName: Schema.optional(Schema.String),
source: Schema.optional(Schema.String),
// kilocode_change end
description: Schema.optional(Schema.String),
deprecated: Schema.optional(Schema.Boolean), // kilocode_change
mode: Schema.Literals(["subagent", "primary", "all"]),
@@ -79,8 +83,10 @@ export interface Interface {
readonly list: () => Effect.Effect<Info[]>
readonly defaultInfo: () => Effect.Effect<Info>
readonly defaultAgent: () => Effect.Effect<string>
readonly requirementStatus: (agent: string) => Effect.Effect<AgentRequirements.Result> // kilocode_change
readonly guardRequirements: (agent: Info) => Effect.Effect<void, RequirementBlockedError> // kilocode_change
// kilocode_change start
readonly requirementStatus: (agent: string) => Effect.Effect<AgentRequirements.Result>
readonly guardRequirements: (agent: Info) => Effect.Effect<void, RequirementBlockedError>
// kilocode_change end
readonly generate: (input: {
description: string
model?: { providerID: ProviderV2.ID; modelID: ModelV2.ID }
@@ -119,13 +125,6 @@ export const layer = Layer.effect(
// kilocode_change start - include global config dirs so agents can read them without prompting
const referenceDirs = yield* Effect.gen(function* () {
yield* (yield* PluginBoot.Service).wait()
// kilocode_change start - V2 tools must use Kilo's effective config precedence.
yield* KiloReference.sync({
references: cfg.references ?? cfg.reference ?? {},
directory: ctx.directory,
worktree: ctx.worktree,
})
// kilocode_change end
return (yield* (yield* Reference.Service).list()).map((reference) => reference.path)
}).pipe(Effect.provide(locations.get(Location.Ref.make({ directory: AbsolutePath.make(ctx.directory) }))))
const whitelistedDirs = [
@@ -142,8 +141,7 @@ export const layer = Layer.effect(
...Object.fromEntries(whitelistedDirs.map((dir) => [dir, "allow"])),
} satisfies Record<string, "allow" | "ask" | "deny">
const baseDefaults = Permission.fromConfig({
// kilocode_change
const baseDefaults = Permission.fromConfig({ // kilocode_change
"*": "allow",
doom_loop: "ask",
external_directory: {
@@ -155,8 +153,10 @@ export const layer = Layer.effect(
interactive_terminal: "deny", // kilocode_change - human-driven tools are primary-agent only
plan_enter: "deny",
plan_exit: "deny",
repo_clone: "deny", // kilocode_change
repo_overview: "deny", // kilocode_change
// kilocode_change start
repo_clone: "deny",
repo_overview: "deny",
// kilocode_change end
// mirrors github.com/github/gitignore Node.gitignore pattern for .env files
read: {
"*": "allow",
@@ -182,8 +182,10 @@ export const layer = Layer.effect(
defaults,
Permission.fromConfig({
question: "allow",
interactive_terminal: "allow", // kilocode_change
suggest: "allow", // kilocode_change
// kilocode_change start
interactive_terminal: "allow",
suggest: "allow",
// kilocode_change end
plan_enter: "allow",
}),
user,
@@ -294,7 +296,7 @@ export const layer = Layer.effect(
prompt: PROMPT_COMPACTION,
permission: Permission.merge(
defaults,
user,
user, // kilocode_change
Permission.fromConfig({
"*": "deny",
}),
@@ -310,7 +312,7 @@ export const layer = Layer.effect(
temperature: 0.5,
permission: Permission.merge(
defaults,
user,
user, // kilocode_change
Permission.fromConfig({
"*": "deny",
}),
@@ -325,7 +327,7 @@ export const layer = Layer.effect(
hidden: true,
permission: Permission.merge(
defaults,
user,
user, // kilocode_change
Permission.fromConfig({
"*": "deny",
}),
@@ -371,7 +373,8 @@ export const layer = Layer.effect(
// kilocode_change end
item.options = mergeDeep(item.options, value.options ?? {})
item.permission = Permission.merge(item.permission, Permission.fromConfig(value.permission ?? {}))
KiloAgent.processConfigItem(item) // kilocode_change - populate displayName from options
// kilocode_change start
KiloAgent.processConfigItem(item)
}
function referencePrompt(reference: KiloReference.Resolved) {
@@ -398,7 +401,7 @@ export const layer = Layer.effect(
`Repository: ${reference.repository}`,
...(reference.branch ? [`Branch/ref: ${reference.branch}`] : []),
`Cached directory: ${reference.path}`,
`Kilo materializes this configured repository before use. Do not call repo_clone for this reference.`, // kilocode_change
`Kilo materializes this configured repository before use. Do not call repo_clone for this reference.`,
`Inspect the cached directory as the primary reference source. Prefer repo_overview with path ${JSON.stringify(reference.path)} before broader searches, then use Glob, Grep, and Read inside that directory. Do not edit files.`,
`Return exact absolute file paths for findings whenever possible.`,
].join("\n\n")
@@ -411,9 +414,9 @@ export const layer = Layer.effect(
}
if (flags.experimentalScout) {
const references = cfg.references ?? cfg.reference ?? {} // kilocode_change - prefer the supported key
const references = cfg.references ?? cfg.reference ?? {}
const resolvedReferences = KiloReference.resolveAll({
references, // kilocode_change
references,
directory: ctx.directory,
worktree: ctx.worktree,
})
@@ -438,11 +441,12 @@ export const layer = Layer.effect(
}),
),
prompt: referencePrompt(resolved),
options: { reference: references[resolved.name], resolved }, // kilocode_change
options: { reference: references[resolved.name], resolved },
mode: "subagent",
native: false,
}
}
// kilocode_change end
}
// Ensure Truncate.GLOB is allowed unless explicitly configured
@@ -522,9 +526,7 @@ export const layer = Layer.effect(
yield* InstanceState.invalidate(state)
return yield* select(yield* InstanceState.get(state))
})
// kilocode_change end
// kilocode_change start - agent requirement status and guard hooks
const requirementStatus = Effect.fn("Agent.requirementStatus")(function* (name: string) {
const ctx = yield* InstanceState.context
return yield* AgentRequirements.status({
@@ -563,8 +565,10 @@ export const layer = Layer.effect(
defaultAgent: Effect.fn("Agent.defaultAgent")(function* () {
return yield* current((s) => s.defaultAgent()) // kilocode_change
}),
requirementStatus, // kilocode_change
guardRequirements, // kilocode_change
// kilocode_change start
requirementStatus,
guardRequirements,
// kilocode_change end
generate: Effect.fn("Agent.generate")(function* (input: {
description: string
model?: { providerID: ProviderV2.ID; modelID: ModelV2.ID }
@@ -639,8 +643,10 @@ export const defaultLayer: Layer.Layer<Service> = layer.pipe(
Layer.provide(Auth.defaultLayer),
Layer.provide(Config.defaultLayer),
Layer.provide(Skill.defaultLayer),
Layer.provide(MCP.defaultLayer), // kilocode_change
// kilocode_change start
Layer.provide(MCP.defaultLayer),
Layer.provide(RuntimeFlags.defaultLayer),
// kilocode_change end
Layer.provide(LocationServiceMap.layer),
)
@@ -652,8 +658,10 @@ export const node = LayerNode.make(layer, [
Plugin.node,
Skill.node,
Provider.node,
// kilocode_change start
MCP.node,
RuntimeFlags.node,
// kilocode_change end
locationServiceMapNode,
])
+10 -5
View File
@@ -17,6 +17,7 @@ import { Process } from "@/util/process"
import { errorMessage } from "@/util/error"
import { text } from "node:stream/consumers"
import { Effect, Option } from "effect"
import { remove as removeAuth } from "@/kilocode/auth/remove" // kilocode_change
type PluginAuth = NonNullable<Hooks["auth"]>
@@ -306,7 +307,7 @@ export const ProvidersLoginCommand = effectCmd({
builder: (yargs: Argv) =>
yargs
.positional("url", {
describe: "kilo auth provider", // kilocode_change
describe: "kilo auth provider",
type: "string",
})
.option("provider", {
@@ -386,9 +387,11 @@ export const ProvidersLoginCommand = effectCmd({
existingProviders: providers,
disabled,
enabled,
// kilocode_change start
providerNames: Object.fromEntries(
Object.entries(config.provider ?? {}).flatMap(([id, p]) => (p ? [[id, p.name]] : [])),
), // kilocode_change
),
// kilocode_change end
})
const options = [
...pipe(
@@ -402,8 +405,10 @@ export const ProvidersLoginCommand = effectCmd({
label: x.name,
value: x.id,
hint: {
kilo: "recommended", // kilocode_change
openai: "ChatGPT login or API key", // kilocode_change
// kilocode_change start
kilo: "recommended",
openai: "ChatGPT login or API key",
// kilocode_change end
}[x.id],
})),
),
@@ -552,7 +557,7 @@ export const ProvidersLogoutCommand = effectCmd({
}),
)
if (!provider) return yield* fail(`Unknown configured provider "${args.provider}"`)
yield* Effect.orDie(authSvc.remove(provider))
yield* removeAuth(provider) // kilocode_change
yield* Prompt.outro("Logout successful")
}),
})
+27 -17
View File
@@ -4,6 +4,7 @@ import * as Observability from "@opencode-ai/core/observability"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Database } from "@opencode-ai/core/database/database"
import { Credential } from "@opencode-ai/core/credential" // kilocode_change
import { Auth } from "@/auth"
import { Account } from "@/account/account"
import { Config } from "@/config/config"
@@ -52,18 +53,23 @@ import { Npm } from "@opencode-ai/core/npm"
import { memoMap } from "@opencode-ai/core/effect/memo-map"
import { BackgroundJob } from "@/background/job"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { Notebook } from "@/kilocode/notebook/service" // kilocode_change
import { AgentManager } from "@/kilocode/agent-manager/service" // kilocode_change
// kilocode_change start
import { Notebook } from "@/kilocode/notebook/service"
import { AgentManager } from "@/kilocode/agent-manager/service"
// kilocode_change end
import { EventV2Bridge } from "@/event-v2-bridge"
import { ProjectV2 } from "@opencode-ai/core/project" // kilocode_change - listener routes are provided by AppLayer
import { ProjectCopy } from "@opencode-ai/core/project/copy" // kilocode_change - listener routes are provided by AppLayer
import { MoveSession } from "@opencode-ai/core/control-plane/move-session" // kilocode_change - listener routes are provided by AppLayer
import { PtyTicket } from "@opencode-ai/core/pty/ticket" // kilocode_change - listener routes are provided by AppLayer
// kilocode_change start
import { ProjectV2 } from "@opencode-ai/core/project"
import { ProjectCopy } from "@opencode-ai/core/project/copy"
import { MoveSession } from "@opencode-ai/core/control-plane/move-session"
import { PtyTicket } from "@opencode-ai/core/pty/ticket"
// kilocode_change end
const CoreLayer = Layer.mergeAll(
const CoreLayer = Layer.mergeAll( // kilocode_change
Npm.defaultLayer,
FSUtil.defaultLayer,
Database.defaultLayer,
Credential.defaultLayer, // kilocode_change
Auth.defaultLayer,
Account.defaultLayer,
Config.defaultLayer,
@@ -78,10 +84,12 @@ const CoreLayer = Layer.mergeAll(
Agent.defaultLayer,
Skill.defaultLayer,
Discovery.defaultLayer,
)
) // kilocode_change
// kilocode_change start
const SessionLayer = Layer.mergeAll(
AgentManager.defaultLayer, // kilocode_change
AgentManager.defaultLayer,
// kilocode_change end
Question.defaultLayer,
Notebook.defaultLayer, // kilocode_change
Permission.defaultLayer,
@@ -104,16 +112,18 @@ const SessionLayer = Layer.mergeAll(
McpAuth.defaultLayer,
Command.defaultLayer,
Truncate.defaultLayer,
)
) // kilocode_change
const FeatureLayer = Layer.mergeAll(
const FeatureLayer = Layer.mergeAll( // kilocode_change
ToolRegistry.defaultLayer,
Format.defaultLayer,
Project.defaultLayer,
ProjectV2.defaultLayer, // kilocode_change - satisfy listener route handlers through AppLayer
ProjectCopy.defaultLayer, // kilocode_change - satisfy listener route handlers through AppLayer
MoveSession.defaultLayer, // kilocode_change - satisfy listener route handlers through AppLayer
PtyTicket.defaultLayer, // kilocode_change - satisfy listener route handlers through AppLayer
// kilocode_change start
ProjectV2.defaultLayer,
ProjectCopy.defaultLayer,
MoveSession.defaultLayer,
PtyTicket.defaultLayer,
// kilocode_change end
Vcs.defaultLayer,
Workspace.defaultLayer,
Worktree.appLayer,
@@ -121,9 +131,9 @@ const FeatureLayer = Layer.mergeAll(
MemoryService.layer, // kilocode_change
ShareNext.defaultLayer,
SessionShare.defaultLayer,
)
) // kilocode_change
export const AppLayer = Layer.mergeAll(CoreLayer, SessionLayer, FeatureLayer).pipe(
export const AppLayer = Layer.mergeAll(CoreLayer, SessionLayer, FeatureLayer).pipe( // kilocode_change
Layer.provideMerge(Ripgrep.defaultLayer),
Layer.provideMerge(InstanceLayer.layer),
Layer.provideMerge(Observability.layer),
@@ -41,7 +41,7 @@ export class Service extends ConfigService.Service<Service>()("@opencode/Runtime
}).pipe(Config.map((flags) => flags.enabled || flags.legacy)),
enableExperimentalModels: bool("KILO_ENABLE_EXPERIMENTAL_MODELS"),
enableQuestionTool: bool("KILO_ENABLE_QUESTION_TOOL"),
experimentalScout: enabledByExperimental("KILO_EXPERIMENTAL_SCOUT"),
experimentalScout: enabledByExperimental("KILO_EXPERIMENTAL_SCOUT"), // kilocode_change
experimentalReferences: enabledByExperimental("KILO_EXPERIMENTAL_REFERENCES"),
experimentalBackgroundSubagents: enabledByExperimental("KILO_EXPERIMENTAL_BACKGROUND_SUBAGENTS"),
experimentalLspTy: bool("KILO_EXPERIMENTAL_LSP_TY"),
@@ -49,6 +49,7 @@ export class Service extends ConfigService.Service<Service>()("@opencode/Runtime
experimentalOxfmt: enabledByExperimental("KILO_EXPERIMENTAL_OXFMT"),
experimentalPlanMode: enabledByExperimental("KILO_EXPERIMENTAL_PLAN_MODE"),
experimentalEventSystem: enabledByExperimental("KILO_EXPERIMENTAL_EVENT_SYSTEM"),
experimentalSessionSwitcher: enabledByExperimental("KILO_EXPERIMENTAL_SESSION_SWITCHER"), // kilocode_change
experimentalWorkspaces: enabledByExperimental("KILO_EXPERIMENTAL_WORKSPACES"),
experimentalIconDiscovery: enabledByExperimental("KILO_EXPERIMENTAL_ICON_DISCOVERY"),
outputTokenMax: positiveInteger("KILO_EXPERIMENTAL_OUTPUT_TOKEN_MAX"),
@@ -0,0 +1,16 @@
import { Auth } from "@/auth"
import { ConnectorSchema } from "@opencode-ai/core/connector/schema"
import { Credential } from "@opencode-ai/core/credential"
import { Effect } from "effect"
export const remove = Effect.fn("KiloAuth.remove")(function* (key: string) {
const auth = yield* Auth.Service
const credentials = yield* Credential.Service
const connectorID = ConnectorSchema.ID.make(key.replace(/\/+$/, ""))
const existing = yield* credentials.forConnector(connectorID)
yield* Effect.forEach(existing, (credential) => credentials.remove(credential.id), {
concurrency: 1,
discard: true,
})
yield* auth.remove(key).pipe(Effect.orDie)
})
@@ -14,6 +14,9 @@ import SidebarUsage from "@/kilocode/plugins/sidebar-usage"
import Sandbox from "@/kilocode/plugins/sandbox"
import Remote from "@/kilocode/plugins/remote"
import Reload from "@/kilocode/plugins/reload"
import SessionSwitcher from "@/kilocode/plugins/session-switcher"
import SessionV2Debug from "@/kilocode/plugins/session-v2-debug"
import type { RuntimeFlags } from "@/effect/runtime-flags"
const plugins = [
HomeNews,
@@ -33,6 +36,14 @@ const plugins = [
Reload,
] satisfies BuiltinTuiPlugin[]
export function withKiloTuiPlugins(builtins: BuiltinTuiPlugin[]) {
return [...plugins, ...builtins]
export function withKiloTuiPlugins(
builtins: BuiltinTuiPlugin[],
flags: Pick<RuntimeFlags.Info, "experimentalEventSystem" | "experimentalSessionSwitcher">,
) {
return [
...plugins,
...(flags.experimentalEventSystem ? [SessionV2Debug] : []),
...(flags.experimentalSessionSwitcher ? [SessionSwitcher] : []),
...builtins,
]
}
@@ -0,0 +1,356 @@
import { useDialog } from "@tui/ui/dialog"
import { DialogSelect, type DialogSelectOption, type DialogSelectRef } from "@tui/ui/dialog-select"
import { useRoute } from "@tui/context/route"
import { useSync } from "@tui/context/sync"
import { useProject } from "@tui/context/project"
import { useTheme } from "@tui/context/theme"
import { useSDK } from "@tui/context/sdk"
import { useLocal } from "@tui/context/local"
import { useToast } from "@tui/ui/toast"
import { useCommandShortcut } from "@tui/keymap"
import { createEffect, createMemo, createResource, createSignal, on, Show, untrack } from "solid-js"
import { useTerminalDimensions } from "@opentui/solid"
import { Spinner } from "@tui/component/spinner"
import { DialogSessionRename } from "@tui/component/dialog-session-rename"
import { DialogSessionDeleteFailed } from "@tui/component/dialog-session-delete-failed"
import {
openWorkspaceSelect,
type WorkspaceSelection,
warpWorkspaceSession,
} from "@tui/component/dialog-workspace-create"
import { createDebouncedSignal } from "@tui/util/signal"
import { errorMessage } from "@/util/error"
import { SessionPreviewPane, createLeadingTrailingSignal } from "./preview-pane"
import { relativeTime } from "./util"
export function SessionSwitcherDialog() {
const dialog = useDialog()
const route = useRoute()
const sync = useSync()
const project = useProject()
const { theme } = useTheme()
const sdk = useSDK()
const local = useLocal()
const toast = useToast()
const dimensions = useTerminalDimensions()
const [toDelete, setToDelete] = createSignal<string>()
const [search, setSearch] = createDebouncedSignal("", 150)
const deleteHint = useCommandShortcut("session.delete")
const quickSwitch1 = useCommandShortcut("session.quick_switch.1")
const quickSwitch9 = useCommandShortcut("session.quick_switch.9")
let select: DialogSelectRef<string> | undefined
const [searchResults, { refetch }] = createResource(
() => ({ query: search(), filter: sync.session.query() }),
async (input) => {
if (!input.query) return undefined
const result = await sdk.client.session.list({ search: input.query, limit: 30, ...input.filter })
return result.data ?? []
},
)
const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined))
const sessions = createMemo(() => searchResults() ?? sync.data.session)
const [focusedSession, setFocusedSession, scheduleFocused] = createLeadingTrailingSignal<string | undefined>(
undefined,
150,
)
const focusedSessionInfo = createMemo(() => {
const id = focusedSession()
if (!id) return undefined
return sessions().find((session) => session.id === id) ?? sync.data.session.find((session) => session.id === id)
})
function recoverFailed(session: NonNullable<ReturnType<typeof sessions>[number]>) {
const workspace = project.workspace.get(session.workspaceID!)
const list = () => dialog.replace(() => <SessionSwitcherDialog />)
const warp = async (selection: WorkspaceSelection) => {
const workspaceID = await (async () => {
if (selection.type === "none") return null
if (selection.type === "existing") return selection.workspaceID
const result = await sdk.client.experimental.workspace
.create({ type: selection.workspaceType, branch: null })
.catch(() => undefined)
const created = result?.data
if (!created) {
toast.show({
message: `Failed to create workspace: ${errorMessage(result?.error ?? "no response")}`,
variant: "error",
})
return
}
await project.workspace.sync()
return created.id
})()
if (workspaceID === undefined) return
await warpWorkspaceSession({
dialog,
sdk,
sync,
project,
toast,
sourceWorkspaceID: session.workspaceID,
workspaceID,
sessionID: session.id,
copyChanges: false,
done: list,
})
}
dialog.replace(() => (
<DialogSessionDeleteFailed
session={session.title}
workspace={workspace?.name ?? session.workspaceID!}
onDone={list}
onDelete={async () => {
const current = currentSessionID()
const info = current ? sync.data.session.find((item) => item.id === current) : undefined
const result = await sdk.client.experimental.workspace.remove({ id: session.workspaceID! })
if (result.error) {
toast.show({
variant: "error",
title: "Failed to delete workspace",
message: errorMessage(result.error),
})
return false
}
await project.workspace.sync()
await sync.session.refresh()
if (search()) await refetch()
if (info?.workspaceID === session.workspaceID) {
route.navigate({ type: "home" })
}
return true
}}
onRestore={() => {
void openWorkspaceSelect({
dialog,
sdk,
sync,
project,
toast,
onSelect: (selection) => {
void warp(selection)
},
})
return false
}}
/>
))
}
function orderByRecency(sessionsList: NonNullable<ReturnType<typeof sessions>>) {
return sessionsList
.filter((x) => x.parentID === undefined)
.toSorted((a, b) => b.time.updated - a.time.updated)
.map((x) => x.id)
}
const [browseOrder] = createSignal<string[]>(orderByRecency(sync.data.session))
const quickSwitchHint = createMemo(() => {
const first = quickSwitch1()
const last = quickSwitch9()
if (!first || !last) return undefined
return quickSwitchRange(first, last)
})
const options = createMemo<DialogSelectOption<string>[]>(() => {
const today = new Date().toDateString()
const sessionMap = new Map(
sessions()
.filter((x) => x.parentID === undefined)
.map((x) => [x.id, x]),
)
const searchResult = searchResults()
const displayOrder = searchResult ? orderByRecency(searchResult) : browseOrder()
const pinned = local.session.pinned().filter((id) => sessionMap.has(id))
const pinnedSet = new Set(pinned)
const slotByID = new Map<string, number>(local.session.slots().map((id, i) => [id, i + 1]))
function buildOption(id: string, category: string): DialogSelectOption<string> | undefined {
const x = sessionMap.get(id)
if (!x) return undefined
const workspace = x.workspaceID ? project.workspace.get(x.workspaceID) : undefined
const footer = relativeTime(x.time.updated)
const isWorktree = workspace?.type === "worktree"
const isDeleting = toDelete() === x.id
const status = sync.data.session_status?.[x.id]
const isWorking = status?.type === "busy" || status?.type === "retry"
const slot = slotByID.get(x.id)
const gutter =
slot !== undefined || isWorking
? () => (
<box flexDirection="row" gap={1}>
<Show when={slot !== undefined}>
<text fg={theme.accent}>{slot}</text>
</Show>
<Show when={isWorking}>
<Spinner />
</Show>
</box>
)
: undefined
const titleText = isDeleting ? `Press ${deleteHint()} again to confirm` : isWorktree ? `${x.title}` : x.title
return {
title: titleText,
bg: isDeleting ? theme.error : undefined,
value: x.id,
category,
categoryView:
category === "Pinned" ? (
<text>
<span style={{ fg: theme.accent }}>
<b>Pinned</b>
</span>
<Show when={quickSwitchHint()}>
{(hint) => <span style={{ fg: theme.textMuted }}> · switch {hint()}</span>}
</Show>
</text>
) : undefined,
footer,
gutter,
}
}
const remaining = displayOrder
.filter((id) => !pinnedSet.has(id))
.map((id) => {
const x = sessionMap.get(id)
if (!x) return undefined
const label = new Date(x.time.updated).toDateString()
return buildOption(id, label === today ? "Today" : label)
})
.filter((x): x is DialogSelectOption<string> => x !== undefined)
return [
...pinned.map((id) => buildOption(id, "Pinned")).filter((x): x is DialogSelectOption<string> => x !== undefined),
...remaining,
]
})
createEffect(
on([options, currentSessionID], ([items, current]) => {
const selected = untrack(focusedSession)
const selectedID = selected && items.some((item) => item.value === selected) ? selected : undefined
const currentID = current && items.some((item) => item.value === current) ? current : undefined
setFocusedSession(selectedID ?? currentID ?? items[0]?.value)
}),
)
const showPreview = createMemo(() => dimensions().width >= 100)
const height = createMemo(() => Math.max(8, Math.floor(dimensions().height / 2) - 4))
createEffect(() => {
dialog.setSize(showPreview() ? "xlarge" : "large")
})
const list = (
<DialogSelect
ref={(value) => (select = value)}
title="Sessions"
options={options()}
skipFilter={true}
current={currentSessionID()}
onFilter={setSearch}
onMove={(option) => {
setToDelete(undefined)
scheduleFocused(option.value)
}}
onSelect={(option) => {
route.navigate({
type: "session",
sessionID: option.value,
})
dialog.clear()
}}
actions={[
{
command: "session.pin.toggle",
title: "pin/unpin",
onTrigger: (option: { value: string }) => {
local.session.togglePin(option.value)
queueMicrotask(() => select?.moveTo(option.value))
},
},
{
command: "session.delete",
title: "delete",
onTrigger: async (option) => {
if (toDelete() === option.value) {
const session = sessions().find((item) => item.id === option.value)
const status = session?.workspaceID ? project.workspace.status(session.workspaceID) : undefined
try {
const result = await sdk.client.session.delete({
sessionID: option.value,
})
if (result.error) {
if (session?.workspaceID) {
recoverFailed(session)
} else {
toast.show({
variant: "error",
title: "Failed to delete session",
message: errorMessage(result.error),
})
}
setToDelete(undefined)
return
}
} catch (err) {
if (session?.workspaceID) {
recoverFailed(session)
} else {
toast.show({
variant: "error",
title: "Failed to delete session",
message: errorMessage(err),
})
}
setToDelete(undefined)
return
}
if (status && status !== "connected") {
await sync.session.refresh()
}
if (search()) await refetch()
setToDelete(undefined)
return
}
setToDelete(option.value)
},
},
{
command: "session.rename",
title: "rename",
onTrigger: async (option) => {
dialog.replace(() => <DialogSessionRename session={option.value} />)
},
},
]}
/>
)
return (
<box flexDirection="row" width="100%" height={height()}>
<box flexBasis={showPreview() ? 68 : undefined} flexGrow={showPreview() ? 0 : 1} flexShrink={0}>
{list}
</box>
<Show when={showPreview()}>
<box width={1} height={height() - 1} flexShrink={0} border={["left"]} borderColor={theme.borderSubtle} />
<box flexGrow={1} flexShrink={1} flexDirection="column">
<SessionPreviewPane sessionID={focusedSession} session={focusedSessionInfo} />
</box>
</Show>
</box>
)
}
function quickSwitchRange(first: string, last: string) {
const prefix = first.slice(0, -1)
if (first.endsWith("1") && last === `${prefix}9`) return `${prefix}1-9`
return `${first} through ${last}`
}
@@ -0,0 +1,32 @@
import type { TuiPlugin } from "@kilocode/plugin/tui"
import type { InternalTuiPlugin } from "@/plugin/tui/internal"
import { SessionSwitcherDialog } from "./dialog"
const id = "internal:session-switcher"
const tui: TuiPlugin = async (api) => {
api.keymap.registerLayer({
priority: 1000,
commands: [
{
name: "session.list",
title: "Switch session",
category: "Session",
namespace: "palette",
suggested: () => api.state.session.count() > 0,
slashName: "sessions",
slashAliases: ["resume", "continue"],
run() {
api.ui.dialog.replace(() => <SessionSwitcherDialog />)
},
},
],
})
}
const plugin: InternalTuiPlugin = {
id,
tui,
}
export default plugin
@@ -0,0 +1,303 @@
import { createResource, Show, createMemo, createSignal, onCleanup, onMount, type Accessor, type JSX } from "solid-js"
import { TextAttributes } from "@opentui/core"
import { useTerminalDimensions } from "@opentui/solid"
import type { Message, Part, Session as SdkSession } from "@kilocode/sdk/v2"
import { useTheme } from "@tui/context/theme"
import { useSDK } from "@tui/context/sdk"
import { useSync } from "@tui/context/sync"
import { Locale } from "@tui/util/locale"
import { Spinner } from "@tui/component/spinner"
import { extractMessageMarkdown, extractMessageText, relativeTime } from "./util"
type WithParts = { info: Message; parts: Part[] }
type Sdk = ReturnType<typeof useSDK>
type Sync = ReturnType<typeof useSync>
const messageCache = new Map<string, Promise<WithParts[]>>()
function cacheKey(sessionID: string, version: number) {
return `${sessionID}:${version}`
}
function hydrateFromSync(sync: Sync, sessionID: string): WithParts[] | undefined {
const infos = sync.data.message[sessionID]
if (!infos || infos.length === 0) return undefined
return infos.map((info) => ({ info, parts: sync.data.part[info.id] ?? [] }))
}
function loadMessages(sdk: Sdk, sessionID: string, version: number): Promise<WithParts[]> {
const key = cacheKey(sessionID, version)
const cached = messageCache.get(key)
if (cached) return cached
const promise = sdk.client.session
.messages({ sessionID, limit: 50 })
.then((res) => {
if (res.error) throw res.error
return (res.data as WithParts[] | undefined) ?? []
})
.catch((error) => {
messageCache.delete(key)
throw error
})
messageCache.set(key, promise)
return promise
}
export function prefetchPreviews(sdk: Sdk, sync: Sync, sessionIDs: readonly string[]) {
for (const id of sessionIDs) {
const version = sync.data.session.find((session) => session.id === id)?.time.updated ?? 0
if (!hydrateFromSync(sync, id)) loadMessages(sdk, id, version).catch(() => {})
}
}
export function createLeadingTrailingSignal<T>(initial: T, ms: number): [Accessor<T>, (v: T) => void, (v: T) => void] {
const [get, set] = createSignal(initial)
const setNow = (v: T) => set(() => v)
let timer: ReturnType<typeof setTimeout> | undefined
let queued = false
let value = initial
const schedule = (next: T) => {
value = next
if (!timer) setNow(next)
else queued = true
if (timer) clearTimeout(timer)
timer = setTimeout(() => {
timer = undefined
if (!queued) return
queued = false
setNow(value)
}, ms)
}
onCleanup(() => {
if (timer) clearTimeout(timer)
})
return [get, setNow, schedule]
}
export function SessionPreviewPane(props: {
sessionID: Accessor<string | undefined>
session?: Accessor<SdkSession | undefined>
}) {
const { theme } = useTheme()
const sdk = useSDK()
const sync = useSync()
const dimensions = useTerminalDimensions()
const maxHeight = createMemo(() => Math.max(8, Math.floor(dimensions().height / 2) - 4))
const session = createMemo(() => {
const provided = props.session?.()
if (provided) return provided
const id = props.sessionID()
if (!id) return undefined
return sync.data.session.find((s) => s.id === id)
})
const status = createMemo(() => {
const id = props.sessionID()
if (!id) return undefined
return sync.data.session_status?.[id]?.type
})
onMount(() => {
const top = sync.data.session
.filter((s) => s.parentID === undefined)
.slice()
.sort((a, b) => b.time.updated - a.time.updated)
.slice(0, 5)
.map((s) => s.id)
prefetchPreviews(sdk, sync, top)
})
const syncedMessages = createMemo(() => {
const id = props.sessionID()
if (!id) return undefined
return hydrateFromSync(sync, id)
})
const [fetchedMessages] = createResource(
() => {
const id = props.sessionID()
if (!id || syncedMessages()) return undefined
return { sessionID: id, version: session()?.time.updated ?? 0 }
},
async (input) => loadMessages(sdk, input.sessionID, input.version),
)
const messages = createMemo(() => syncedMessages() ?? fetchedMessages() ?? [])
const exchange = createMemo(() => {
const items = messages()
if (!items || items.length === 0) return undefined
const sorted = items.toSorted((a, b) => messageCreated(a) - messageCreated(b))
const user = sorted.findLast((item) => messageRole(item) === "user")
const assistant = user
? sorted.findLast((item) => messageRole(item) === "assistant" && messageParentID(item) === user.info.id)
: sorted.findLast((item) => messageRole(item) === "assistant")
return { user, assistant }
})
const loading = createMemo(() => fetchedMessages.loading && !exchange())
const statusLabel = createMemo(() => {
const s = status()
if (s === "busy") return "working"
if (s === "retry") return "retrying"
return "idle"
})
return (
<box
flexDirection="column"
paddingLeft={2}
paddingRight={2}
paddingTop={1}
paddingBottom={1}
gap={1}
height={maxHeight()}
overflow="hidden"
>
<Show
when={session()}
fallback={
<text fg={theme.textMuted} wrapMode="word">
No session selected
</text>
}
>
{(s) => (
<>
<Header session={s()} statusLabel={statusLabel()} />
<Show when={loading()}>
<Spinner>loading preview...</Spinner>
</Show>
<Show
when={exchange()}
fallback={
<Show when={!loading()}>
<text fg={theme.textMuted} wrapMode="word">
{fetchedMessages.error ? "Preview unavailable" : "No messages yet"}
</text>
</Show>
}
>
{(ex) => <Exchange exchange={ex()} />}
</Show>
</>
)}
</Show>
</box>
)
}
function messageRole(item: WithParts) {
return (item.info as { role?: string }).role
}
function messageCreated(item: WithParts) {
return (item.info.time as { created?: number }).created ?? 0
}
function messageParentID(item: WithParts) {
return (item.info as { parentID?: string }).parentID
}
const ROW_WIDTH = 40
function Header(props: { session: SdkSession; statusLabel: string }) {
const { theme } = useTheme()
const title = createMemo(() => Locale.truncate(props.session.title, ROW_WIDTH))
const statusRest = createMemo(() => {
const joined = ` · ${relativeTime(props.session.time.updated)}`
return Locale.truncate(joined, Math.max(0, ROW_WIDTH - props.statusLabel.length))
})
return (
<box flexDirection="column" gap={0} flexShrink={0}>
<Row height={1}>
<text fg={theme.text} attributes={TextAttributes.BOLD} wrapMode="none" overflow="hidden">
{title()}
</text>
</Row>
<Row height={1}>
<text fg={theme.textMuted} wrapMode="none" overflow="hidden">
<span>{props.statusLabel}</span>
<span>{statusRest()}</span>
</text>
</Row>
</box>
)
}
function Row(props: { height: number; children: JSX.Element }) {
return (
<box height={props.height} flexShrink={0} overflow="hidden">
{props.children}
</box>
)
}
const PROMPT_MAX_CHARS = 240
const REPLY_MAX_LINES = 12
const REPLY_MAX_CHARS = 800
function Exchange(props: { exchange: { user?: WithParts; assistant?: WithParts } }) {
const { theme, syntax } = useTheme()
const userText = createMemo(() =>
props.exchange.user ? extractMessageText(props.exchange.user.parts, PROMPT_MAX_CHARS) : undefined,
)
const assistantMarkdown = createMemo(() =>
props.exchange.assistant
? extractMessageMarkdown(props.exchange.assistant.parts, REPLY_MAX_LINES, REPLY_MAX_CHARS)
: undefined,
)
return (
<box flexDirection="column" gap={1}>
<Show when={userText()}>
<text fg={theme.textMuted} wrapMode="word">
<span style={{ fg: theme.textMuted }}> </span>
{userText()!}
</text>
</Show>
<Show when={assistantMarkdown()}>
<markdown
content={assistantMarkdown()!}
syntaxStyle={syntax()}
streaming={false}
internalBlockMode="top-level"
tableOptions={{ style: "columns" }}
conceal={false}
fg={theme.markdownText}
bg={theme.backgroundPanel}
/>
</Show>
<Show when={!userText() && !assistantMarkdown()}>
<NonTextHint exchange={props.exchange} />
</Show>
</box>
)
}
function NonTextHint(props: { exchange: { user?: WithParts; assistant?: WithParts } }) {
const { theme } = useTheme()
const summary = createMemo(() => {
const counts: Record<string, number> = {}
for (const item of [props.exchange.user, props.exchange.assistant]) {
if (!item) continue
for (const part of item.parts) {
counts[part.type] = (counts[part.type] ?? 0) + 1
}
}
return Object.entries(counts)
.map(([k, n]) => `${n} ${k}`)
.join(", ")
})
return (
<text fg={theme.textMuted} wrapMode="word">
<Show when={summary()} fallback="No text content in the latest messages">
Latest exchange has no text content ({summary()})
</Show>
</text>
)
}
@@ -0,0 +1,53 @@
import type { Part } from "@kilocode/sdk/v2"
import { Locale } from "@tui/util/locale"
export function relativeTime(timestamp: number): string {
const diff = Date.now() - timestamp
if (diff < 0) return "just now"
const seconds = Math.floor(diff / 1000)
if (seconds < 60) return "just now"
const minutes = Math.floor(seconds / 60)
if (minutes < 60) return `${minutes}m ago`
const hours = Math.floor(minutes / 60)
if (hours < 24) return `${hours}h ago`
const days = Math.floor(hours / 24)
if (days < 7) return `${days}d ago`
const d = new Date(timestamp)
return d.toLocaleDateString(undefined, { month: "short", day: "numeric" })
}
export function extractMessageText(parts: readonly Part[], maxLength: number): string {
const joined = collectTextParts(parts).join(" ").replace(/\s+/g, " ").trim()
return Locale.truncate(joined, maxLength)
}
export function extractMessageMarkdown(parts: readonly Part[], maxLines: number, maxChars: number): string {
const joined = collectTextParts(parts).join("\n\n").trim()
if (!joined) return joined
let truncated = joined
const lines = truncated.split("\n")
if (lines.length > maxLines) {
truncated = lines.slice(0, maxLines).join("\n")
}
if (truncated.length > maxChars) {
truncated = truncated.slice(0, maxChars).trimEnd()
}
if (truncated.length === joined.length) return joined
// Close any unterminated fenced code block so the renderer doesn't keep
// the rest of the panel in "code mode".
const fences = (truncated.match(/^```/gm) ?? []).length
if (fences % 2 === 1) truncated += "\n```"
return truncated + "\n\n…"
}
function collectTextParts(parts: readonly Part[]): string[] {
const chunks: string[] = []
for (const part of parts) {
if (part.type !== "text") continue
const p = part as Part & { type: "text"; text: string; synthetic?: boolean; ignored?: boolean }
if (p.synthetic || p.ignored) continue
if (!p.text) continue
chunks.push(p.text)
}
return chunks
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,464 @@
import { useEvent } from "@tui/context/event"
import type {
Event,
SessionMessage,
SessionMessageAssistant,
SessionMessageAssistantReasoning,
SessionMessageAssistantText,
SessionMessageAssistantTool,
} from "@kilocode/sdk/v2"
import { createStore, produce, reconcile } from "solid-js/store"
import { createSimpleContext } from "@tui/context/helper"
import { useSDK } from "@tui/context/sdk"
function activeAssistant(messages: SessionMessage[]) {
const index = messages.findIndex((message) => message.type === "assistant" && !message.time.completed)
if (index < 0) return
const assistant = messages[index]
return assistant?.type === "assistant" ? assistant : undefined
}
function ownedAssistant(messages: SessionMessage[], messageID: string) {
const message = messages.find((message) => message.type === "assistant" && message.id === messageID)
return message?.type === "assistant" ? message : undefined
}
function activeCompaction(messages: SessionMessage[]) {
const index = messages.findIndex((message) => message.type === "compaction")
if (index < 0) return
const compaction = messages[index]
return compaction?.type === "compaction" ? compaction : undefined
}
function activeShell(messages: SessionMessage[], callID: string) {
const index = messages.findIndex((message) => message.type === "shell" && message.callID === callID)
if (index < 0) return
const shell = messages[index]
return shell?.type === "shell" ? shell : undefined
}
function latestTool(assistant: SessionMessageAssistant | undefined, callID?: string) {
return assistant?.content.findLast(
(item): item is SessionMessageAssistantTool => item.type === "tool" && (callID === undefined || item.id === callID),
)
}
function latestText(assistant: SessionMessageAssistant | undefined, textID: string) {
return assistant?.content.findLast(
(item): item is SessionMessageAssistantText => item.type === "text" && item.id === textID,
)
}
function latestReasoning(assistant: SessionMessageAssistant | undefined, reasoningID: string) {
return assistant?.content.findLast(
(item): item is SessionMessageAssistantReasoning => item.type === "reasoning" && item.id === reasoningID,
)
}
function prepend(messages: SessionMessage[], message: SessionMessage) {
if (messages.some((item) => item.id === message.id)) return
messages.unshift(message)
}
export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext({
name: "SyncV2",
init: () => {
const [store, setStore] = createStore<{
messages: {
[sessionID: string]: SessionMessage[]
}
}>({
messages: {},
})
const event = useEvent()
const sdk = useSDK()
const applied = new Set<string>()
const buffering = new Map<string, Event[]>()
const syncing = new Map<string, Promise<void>>()
function duplicate(id: string) {
if (applied.has(id)) return true
applied.add(id)
if (applied.size <= 1000) return false
const oldest = applied.values().next()
if (!oldest.done) applied.delete(oldest.value)
return false
}
function update(sessionID: string, fn: (messages: SessionMessage[]) => void) {
setStore(
"messages",
produce((draft) => {
fn((draft[sessionID] ??= []))
}),
)
}
async function hydrate(sessionID: string) {
const pending: Event[] = []
const before = JSON.parse(JSON.stringify(store.messages[sessionID] ?? [])) as SessionMessage[]
buffering.set(sessionID, pending)
try {
const response = await sdk.client.v2.session.messages({ sessionID })
const messages = response.data?.data ?? []
const snapshotIDs = new Set(messages.map((message) => message.id))
setStore(
"messages",
sessionID,
reconcile([...messages, ...before.filter((message) => !snapshotIDs.has(message.id))]),
)
buffering.delete(sessionID)
for (const event of pending) apply(event)
} catch (error) {
buffering.delete(sessionID)
throw error
}
}
function sync(sessionID: string) {
const existing = syncing.get(sessionID)
if (existing) return existing
const result = hydrate(sessionID).finally(() => syncing.delete(sessionID))
syncing.set(sessionID, result)
return result
}
function apply(event: Event) {
switch (event.type) {
case "session.next.agent.switched":
update(event.properties.sessionID, (draft) => {
prepend(draft, {
id: event.properties.messageID,
type: "agent-switched",
agent: event.properties.agent,
time: { created: event.properties.timestamp },
})
})
break
case "session.next.model.switched":
update(event.properties.sessionID, (draft) => {
prepend(draft, {
id: event.properties.messageID,
type: "model-switched",
model: event.properties.model,
time: { created: event.properties.timestamp },
})
})
break
case "session.next.prompted": {
update(event.properties.sessionID, (draft) => {
prepend(draft, {
id: event.properties.messageID,
type: "user",
text: event.properties.prompt.text,
files: event.properties.prompt.files,
agents: event.properties.prompt.agents,
time: { created: event.properties.timestamp },
})
})
break
}
case "session.next.prompt.admitted":
break
case "session.next.prompt.promoted":
update(event.properties.sessionID, (draft) => {
prepend(draft, {
id: event.properties.messageID,
type: "user",
text: event.properties.prompt.text,
files: event.properties.prompt.files,
agents: event.properties.prompt.agents,
time: { created: event.properties.timeCreated },
})
})
break
case "session.next.context.updated":
update(event.properties.sessionID, (draft) => {
prepend(draft, {
id: event.properties.messageID,
type: "system",
text: event.properties.text,
time: { created: event.properties.timestamp },
})
})
break
case "session.next.synthetic":
update(event.properties.sessionID, (draft) => {
prepend(draft, {
id: event.properties.messageID,
type: "synthetic",
sessionID: event.properties.sessionID,
text: event.properties.text,
time: { created: event.properties.timestamp },
})
})
break
case "session.next.shell.started":
update(event.properties.sessionID, (draft) => {
prepend(draft, {
id: event.properties.messageID,
type: "shell",
callID: event.properties.callID,
command: event.properties.command,
output: "",
time: { created: event.properties.timestamp },
})
})
break
case "session.next.shell.ended":
update(event.properties.sessionID, (draft) => {
const match = activeShell(draft, event.properties.callID)
if (!match) return
match.output = event.properties.output
match.time.completed = event.properties.timestamp
})
break
case "session.next.step.started":
update(event.properties.sessionID, (draft) => {
if (draft.some((message) => message.id === event.properties.assistantMessageID)) return
const currentAssistant = activeAssistant(draft)
if (currentAssistant) currentAssistant.time.completed = event.properties.timestamp
prepend(draft, {
id: event.properties.assistantMessageID,
type: "assistant",
agent: event.properties.agent,
model: event.properties.model,
content: [],
snapshot: event.properties.snapshot ? { start: event.properties.snapshot } : undefined,
time: { created: event.properties.timestamp },
})
})
break
case "session.next.step.ended":
update(event.properties.sessionID, (draft) => {
const currentAssistant = ownedAssistant(draft, event.properties.assistantMessageID)
if (!currentAssistant) return
currentAssistant.time.completed = event.properties.timestamp
currentAssistant.finish = event.properties.finish
currentAssistant.cost = event.properties.cost
currentAssistant.tokens = event.properties.tokens
if (event.properties.snapshot)
currentAssistant.snapshot = { ...currentAssistant.snapshot, end: event.properties.snapshot }
})
break
case "session.next.step.failed":
update(event.properties.sessionID, (draft) => {
const currentAssistant = ownedAssistant(draft, event.properties.assistantMessageID)
if (!currentAssistant) return
currentAssistant.time.completed = event.properties.timestamp
currentAssistant.finish = "error"
currentAssistant.error = event.properties.error
})
break
case "session.next.text.started":
update(event.properties.sessionID, (draft) => {
ownedAssistant(draft, event.properties.assistantMessageID)?.content.push({
type: "text",
id: event.properties.textID,
text: "",
})
})
break
case "session.next.text.delta":
update(event.properties.sessionID, (draft) => {
const match = latestText(
ownedAssistant(draft, event.properties.assistantMessageID),
event.properties.textID,
)
if (match) match.text += event.properties.delta
})
break
case "session.next.text.ended":
update(event.properties.sessionID, (draft) => {
const match = latestText(
ownedAssistant(draft, event.properties.assistantMessageID),
event.properties.textID,
)
if (match) match.text = event.properties.text
})
break
case "session.next.tool.input.started":
update(event.properties.sessionID, (draft) => {
ownedAssistant(draft, event.properties.assistantMessageID)?.content.push({
type: "tool",
id: event.properties.callID,
name: event.properties.name,
time: { created: event.properties.timestamp },
state: { status: "pending", input: "" },
})
})
break
case "session.next.tool.input.delta":
update(event.properties.sessionID, (draft) => {
const match = latestTool(
ownedAssistant(draft, event.properties.assistantMessageID),
event.properties.callID,
)
if (match?.state.status === "pending") match.state.input += event.properties.delta
})
break
case "session.next.tool.input.ended":
update(event.properties.sessionID, (draft) => {
const match = latestTool(
ownedAssistant(draft, event.properties.assistantMessageID),
event.properties.callID,
)
if (match?.state.status === "pending") match.state.input = event.properties.text
})
break
case "session.next.tool.called":
update(event.properties.sessionID, (draft) => {
const match = latestTool(
ownedAssistant(draft, event.properties.assistantMessageID),
event.properties.callID,
)
if (!match) return
match.time.ran = event.properties.timestamp
match.provider = event.properties.provider
match.state = { status: "running", input: event.properties.input, structured: {}, content: [] }
})
break
case "session.next.tool.progress":
update(event.properties.sessionID, (draft) => {
const match = latestTool(
ownedAssistant(draft, event.properties.assistantMessageID),
event.properties.callID,
)
if (match?.state.status !== "running") return
match.state.structured = event.properties.structured
match.state.content = [...event.properties.content]
})
break
case "session.next.tool.success":
update(event.properties.sessionID, (draft) => {
const match = latestTool(
ownedAssistant(draft, event.properties.assistantMessageID),
event.properties.callID,
)
if (match?.state.status !== "running") return
match.state = {
status: "completed",
input: match.state.input,
structured: event.properties.structured,
content: [...event.properties.content],
result: event.properties.result,
}
match.provider = {
executed: event.properties.provider.executed || match.provider?.executed === true,
metadata: match.provider?.metadata,
resultMetadata: event.properties.provider.metadata,
}
match.time.completed = event.properties.timestamp
})
break
case "session.next.tool.failed":
update(event.properties.sessionID, (draft) => {
const match = latestTool(
ownedAssistant(draft, event.properties.assistantMessageID),
event.properties.callID,
)
if (!match || (match.state.status !== "pending" && match.state.status !== "running")) return
match.state = {
status: "error",
error: event.properties.error,
input: typeof match.state.input === "string" ? {} : match.state.input,
structured: match.state.status === "running" ? match.state.structured : {},
content: match.state.status === "running" ? match.state.content : [],
result: event.properties.result,
}
match.provider = {
executed: event.properties.provider.executed || match.provider?.executed === true,
metadata: match.provider?.metadata,
resultMetadata: event.properties.provider.metadata,
}
match.time.completed = event.properties.timestamp
})
break
case "session.next.reasoning.started":
update(event.properties.sessionID, (draft) => {
ownedAssistant(draft, event.properties.assistantMessageID)?.content.push({
type: "reasoning",
id: event.properties.reasoningID,
text: "",
providerMetadata: event.properties.providerMetadata,
})
})
break
case "session.next.reasoning.delta":
update(event.properties.sessionID, (draft) => {
const match = latestReasoning(
ownedAssistant(draft, event.properties.assistantMessageID),
event.properties.reasoningID,
)
if (match) match.text += event.properties.delta
})
break
case "session.next.reasoning.ended":
update(event.properties.sessionID, (draft) => {
const match = latestReasoning(
ownedAssistant(draft, event.properties.assistantMessageID),
event.properties.reasoningID,
)
if (match) {
match.text = event.properties.text
if (event.properties.providerMetadata !== undefined)
match.providerMetadata = event.properties.providerMetadata
}
})
break
case "session.next.retried":
break
case "session.next.compaction.started":
update(event.properties.sessionID, (draft) => {
prepend(draft, {
id: event.properties.messageID,
type: "compaction",
reason: event.properties.reason,
summary: "",
recent: "",
time: { created: event.properties.timestamp },
})
})
break
case "session.next.compaction.delta":
update(event.properties.sessionID, (draft) => {
const match = activeCompaction(draft)
if (match) match.summary += event.properties.text
})
break
case "session.next.compaction.ended":
update(event.properties.sessionID, (draft) => {
const match = activeCompaction(draft)
if (!match) return
match.summary = event.properties.text
match.recent = event.properties.recent ?? ""
})
break
}
}
event.subscribe((event) => {
if (duplicate(event.id)) return
if ("sessionID" in event.properties && typeof event.properties.sessionID === "string")
buffering.get(event.properties.sessionID)?.push(event)
apply(event)
})
const result = {
data: store,
session: {
message: {
sync,
fromSession(sessionID: string) {
const messages = store.messages[sessionID]
if (!messages) return []
return messages
},
},
},
}
return result
},
})
+29 -5
View File
@@ -13,6 +13,8 @@ export type Resolved =
name: string
kind: "local"
path: string
description?: string
hidden?: boolean
}
| {
name: string
@@ -21,6 +23,8 @@ export type Resolved =
reference: RemoteReference
path: string
branch?: string
description?: string
hidden?: boolean
}
| {
name: string
@@ -30,8 +34,8 @@ export type Resolved =
}
type Normalized =
| { kind: "local"; path: string }
| { kind: "git"; repository: string; branch?: string }
| { kind: "local"; path: string; description?: string; hidden?: boolean }
| { kind: "git"; repository: string; branch?: string; description?: string; hidden?: boolean }
| { kind: "invalid"; message: string }
function normalize(name: string, entry: ConfigReference.Entry): Normalized {
@@ -45,8 +49,16 @@ function normalize(name: string, entry: ConfigReference.Entry): Normalized {
}
return { kind: "git", repository: entry }
}
if ("path" in entry) return { kind: "local", path: entry.path }
return { kind: "git", repository: entry.repository, branch: entry.branch }
if ("path" in entry) {
return { kind: "local", path: entry.path, description: entry.description, hidden: entry.hidden }
}
return {
kind: "git",
repository: entry.repository,
branch: entry.branch,
description: entry.description,
hidden: entry.hidden,
}
}
function local(input: { directory: string; worktree: string; value: string }) {
@@ -58,7 +70,13 @@ function local(input: { directory: string; worktree: string; value: string }) {
function resolve(name: string, entry: Normalized, directory: string, worktree: string): Resolved {
if (entry.kind === "invalid") return { name, kind: "invalid", message: entry.message }
if (entry.kind === "local") {
return { name, kind: "local", path: local({ directory, worktree, value: entry.path }) }
return {
name,
kind: "local",
path: local({ directory, worktree, value: entry.path }),
description: entry.description,
hidden: entry.hidden,
}
}
const reference = parseRepositoryReference(entry.repository)
if (!reference || reference.protocol === "file:") {
@@ -76,6 +94,8 @@ function resolve(name: string, entry: Normalized, directory: string, worktree: s
reference,
path: repositoryCachePath(reference),
branch: entry.branch,
description: entry.description,
hidden: entry.hidden,
}
}
@@ -131,6 +151,8 @@ export const sync = Effect.fn("KiloReference.sync")(function* (input: {
new Reference.LocalSource({
type: "local",
path: AbsolutePath.make(item.path),
description: item.description,
hidden: item.hidden,
}),
] as const,
]
@@ -142,6 +164,8 @@ export const sync = Effect.fn("KiloReference.sync")(function* (input: {
type: "git",
repository: item.repository,
branch: item.branch,
description: item.description,
hidden: item.hidden,
}),
] as const,
]
@@ -0,0 +1,32 @@
import { Config } from "@/config/config"
import { InstanceRef } from "@/effect/instance-ref"
import { isInterrupted } from "@/kilocode/effect/cause"
import * as KiloReference from "@/kilocode/reference"
import { InstanceStore } from "@/project/instance-store"
import { Location } from "@opencode-ai/core/location"
import { PluginBoot } from "@opencode-ai/core/plugin/boot"
import { ReferenceReconciler } from "@opencode-ai/server/kilocode/reference-reconciler"
import { Effect, Layer } from "effect"
export const layer = Layer.effect(
ReferenceReconciler,
Effect.gen(function* () {
const config = yield* Config.Service
const store = yield* InstanceStore.Service
return Effect.gen(function* () {
const location = yield* Location.Service
const ctx = yield* store.load({ directory: location.directory })
const cfg = yield* config.get().pipe(Effect.provideService(InstanceRef, ctx))
yield* PluginBoot.Service.use((boot) => boot.wait())
yield* KiloReference.sync({
references: cfg.references ?? cfg.reference ?? {},
directory: ctx.directory,
worktree: ctx.worktree,
}).pipe(
Effect.catchCause((cause) =>
isInterrupted(cause) ? Effect.interrupt : Effect.logWarning("reference sync failed", { cause }),
),
)
})
}),
)
+5 -2
View File
@@ -4,12 +4,15 @@ import { withKiloTuiPlugins } from "@/kilocode/plugins/internal" // kilocode_cha
export type InternalTuiPlugin = BuiltinTuiPlugin
export function internalTuiPlugins(flags: Pick<RuntimeFlags.Info, "experimentalEventSystem">): InternalTuiPlugin[] {
// kilocode_change start - register Kilo plugins before upstream builtins
// kilocode_change start
export function internalTuiPlugins(
flags: Pick<RuntimeFlags.Info, "experimentalEventSystem" | "experimentalSessionSwitcher">,
): InternalTuiPlugin[] {
return withKiloTuiPlugins(
createBuiltinPlugins({
experimentalEventSystem: flags.experimentalEventSystem,
}),
flags,
)
// kilocode_change end
}
@@ -1,13 +1,16 @@
import { Auth } from "@/auth"
// kilocode_change start
import {
invalidateAfterProviderAuthChange,
invalidatePresence,
} from "@/kilocode/server/provider-auth-lifecycle" // kilocode_change
} from "@/kilocode/server/provider-auth-lifecycle"
// kilocode_change end
import { Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { RootHttpApi } from "../api"
import { LogInput } from "../groups/control"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { remove as removeAuth } from "@/kilocode/auth/remove" // kilocode_change
export const controlHandlers = HttpApiBuilder.group(RootHttpApi, "control", (handlers) =>
Effect.gen(function* () {
@@ -20,19 +23,19 @@ export const controlHandlers = HttpApiBuilder.group(RootHttpApi, "control", (han
yield* auth.set(ctx.params.providerID, ctx.payload).pipe(Effect.orDie)
// kilocode_change start - drop old presence socket before instance disposal on Kilo auth changes
if (ctx.params.providerID === "kilo") yield* invalidatePresence()
yield* invalidateAfterProviderAuthChange(ctx.params.providerID)
// kilocode_change end
yield* invalidateAfterProviderAuthChange(ctx.params.providerID) // kilocode_change
return true
})
const authRemove = Effect.fn("ControlHttpApi.authRemove")(function* (ctx: {
params: { providerID: ProviderV2.ID }
}) {
yield* auth.remove(ctx.params.providerID).pipe(Effect.orDie)
// kilocode_change start - drop old presence socket before instance disposal on Kilo auth changes
// kilocode_change start
yield* removeAuth(ctx.params.providerID)
if (ctx.params.providerID === "kilo") yield* invalidatePresence()
yield* invalidateAfterProviderAuthChange(ctx.params.providerID)
// kilocode_change end
yield* invalidateAfterProviderAuthChange(ctx.params.providerID) // kilocode_change
return true
})
@@ -37,9 +37,11 @@ import { ModelCache } from "@/provider/model-cache" // kilocode_change
import { Provider } from "@/provider/provider"
import { PtyTicket } from "@opencode-ai/core/pty/ticket"
import { Question } from "@/question"
import { Notebook } from "@/kilocode/notebook/service" // kilocode_change
import { AgentManager } from "@/kilocode/agent-manager/service" // kilocode_change
import { KiloViewers } from "@/kilocode/presence/service" // kilocode_change
// kilocode_change start
import { Notebook } from "@/kilocode/notebook/service"
import { AgentManager } from "@/kilocode/agent-manager/service"
import { KiloViewers } from "@/kilocode/presence/service"
// kilocode_change end
import { Session } from "@/session/session"
import { SessionCompaction } from "@/session/compaction"
import { LLM } from "@/session/llm"
@@ -54,10 +56,13 @@ import { ShareNext } from "@/share/share-next"
import { EventV2Bridge } from "@/event-v2-bridge"
import { EventV2 } from "@opencode-ai/core/event"
import { Database } from "@opencode-ai/core/database/database"
import { Credential } from "@opencode-ai/core/credential" // kilocode_change
import { Skill } from "@/skill"
import { Snapshot } from "@/snapshot"
import { Storage } from "@/storage/storage" // kilocode_change
// kilocode_change start
import { Storage } from "@/storage/storage"
import { SyncEvent } from "@/sync"
// kilocode_change end
import { ToolRegistry } from "@/tool/registry"
import { lazy } from "@/util/lazy"
import { Vcs } from "@/project/vcs"
@@ -97,6 +102,7 @@ import { sessionHandlers } from "./handlers/session"
import { syncHandlers } from "./handlers/sync"
import { tuiHandlers } from "./handlers/tui"
import { handlers } from "@opencode-ai/server/handlers"
import { layer as referenceReconcilerLayer } from "@/kilocode/server/reference-reconciler" // kilocode_change
import { schemaErrorLayer as v2SchemaErrorLayer } from "@opencode-ai/server/middleware/schema-error"
import { workspaceHandlers } from "./handlers/workspace"
// kilocode_change start
@@ -175,7 +181,7 @@ const instanceRoutes = instanceApiRoutes.pipe(
Layer.provide([httpApiAuthLayer, workspaceRoutingLive, instanceContextLayer, schemaErrorLayer]),
)
const serverRoutes = HttpApiBuilder.layer(Api).pipe(
Layer.provide(handlers),
Layer.provide(handlers.pipe(Layer.provide(referenceReconcilerLayer))), // kilocode_change
Layer.provide([serverHttpApiAuthLayer, v2SchemaErrorLayer]),
)
@@ -227,6 +233,7 @@ export function createRoutes(
fenceLayer.pipe(Layer.provide(Database.defaultLayer)),
cors(corsOptions),
Database.defaultLayer,
Credential.defaultLayer, // kilocode_change
Account.defaultLayer,
Agent.defaultLayer,
Auth.defaultLayer,
@@ -252,10 +259,12 @@ export function createRoutes(
Provider.defaultLayer,
PtyTicket.defaultLayer,
Question.defaultLayer,
AgentManager.defaultLayer, // kilocode_change
Notebook.defaultLayer, // kilocode_change
KiloViewers.defaultLayer, // kilocode_change
// kilocode_change start
AgentManager.defaultLayer,
Notebook.defaultLayer,
KiloViewers.defaultLayer,
Ripgrep.defaultLayer,
// kilocode_change end
RuntimeFlags.defaultLayer,
Session.defaultLayer,
SessionCompaction.defaultLayer,
@@ -267,8 +276,10 @@ export function createRoutes(
SessionSummary.defaultLayer,
ShareNext.defaultLayer,
Snapshot.defaultLayer,
Storage.defaultLayer, // kilocode_change
// kilocode_change start
Storage.defaultLayer,
SyncEvent.defaultLayer,
// kilocode_change end
EventV2Bridge.defaultLayer,
EventV2.defaultLayer,
Skill.defaultLayer,
+5 -4
View File
@@ -69,11 +69,12 @@ export const GrepTool = Tool.define(
limit: 100,
signal: ctx.abort, // kilocode_change - stop ripgrep when the tool call is cancelled
})
const matches = result.items // kilocode_change - retain bounded-search metadata from Core ripgrep
if (matches.length === 0) return empty // kilocode_change
// kilocode_change start
const matches = result.items
if (matches.length === 0) return empty
// kilocode_change end
const rows = matches.map((item) => ({
// kilocode_change
const rows = matches.map((item) => ({ // kilocode_change
path: path.resolve(cwd, item.entry.path),
line: item.line,
text: item.text,
@@ -1,6 +1,7 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import path from "path"
import { mkdir } from "node:fs/promises" // kilocode_change
import { cliIt } from "../lib/cli-process"
describe("opencode mcp add (non-interactive subprocess)", () => {
@@ -71,4 +72,27 @@ describe("opencode mcp add (non-interactive subprocess)", () => {
}),
60_000,
)
// kilocode_change start
cliIt.concurrent(
"writes to KILO_CONFIG_DIR without touching the default profile",
({ home, opencode }) =>
Effect.gen(function* () {
const profile = path.join(home, "profile")
yield* Effect.promise(() => mkdir(profile, { recursive: true }))
const result = yield* opencode.spawn(
["mcp", "add", "profile", "--url", "https://example.com/profile"],
{ env: { KILO_CONFIG_DIR: profile } },
)
opencode.expectExit(result, 0)
const config = yield* Effect.promise(() => Bun.file(path.join(profile, "kilo.json")).json())
expect(config.mcp.profile).toEqual({ type: "remote", url: "https://example.com/profile" })
expect(yield* Effect.promise(() => Bun.file(path.join(home, ".config", "kilo", "kilo.json")).exists())).toBe(
false,
)
}),
60_000,
)
// kilocode_change end
})
@@ -0,0 +1,47 @@
import { expect } from "bun:test"
import { Auth } from "@/auth"
import { remove } from "@/kilocode/auth/remove"
import { ConnectorSchema } from "@opencode-ai/core/connector/schema"
import { Credential } from "@opencode-ai/core/credential"
import { Database } from "@opencode-ai/core/database/database"
import { EventV2 } from "@opencode-ai/core/event"
import { Effect, Layer } from "effect"
import { testEffect } from "../lib/effect"
const database = Database.layerFromPath(":memory:")
const events = Layer.mock(EventV2.Service)({
publish: (definition, data) =>
Effect.succeed({ id: EventV2.ID.create(), type: definition.type, data }),
})
const credentials = Credential.layer.pipe(Layer.provide(database), Layer.provide(events))
const state = { removed: false }
const auth = Layer.mock(Auth.Service)({
remove: () => Effect.sync(() => void (state.removed = true)),
})
const it = testEffect(Layer.mergeAll(database, credentials, auth))
it.effect("legacy provider logout removes every Core credential", () =>
Effect.gen(function* () {
state.removed = false
const service = yield* Credential.Service
const connectorID = ConnectorSchema.ID.make("anthropic")
yield* service.create({
connectorID,
methodID: ConnectorSchema.MethodID.make("api-key"),
label: "first",
value: new Credential.Key({ type: "key", key: "first" }),
})
yield* service.create({
connectorID,
methodID: ConnectorSchema.MethodID.make("api-key"),
label: "second",
value: new Credential.Key({ type: "key", key: "second" }),
})
yield* remove("anthropic")
expect(yield* service.forConnector(connectorID)).toEqual([])
expect(yield* service.active(connectorID)).toBeUndefined()
expect(state.removed).toBe(true)
}),
)
@@ -1,7 +1,11 @@
import { describe, expect, test } from "bun:test"
import { Cause, Effect, Exit } from "effect"
import path from "path"
import { Cause, Effect, Exit, Layer } from "effect"
import { RepositoryCache } from "@opencode-ai/core/repository-cache"
import * as Reference from "../../src/kilocode/reference"
import { Reference as CoreReference } from "@opencode-ai/core/reference"
import { EventV2 } from "@opencode-ai/core/event"
import { Global } from "@opencode-ai/core/global"
function remote() {
const item = Reference.resolveAll({
@@ -21,4 +25,46 @@ describe("configured references", () => {
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) expect(Cause.hasInterruptsOnly(exit.cause)).toBe(true)
})
test("sync preserves effective reference metadata", async () => {
const cache = Layer.mock(RepositoryCache.Service, {
ensure: () => Effect.die("unexpected Git materialization"),
})
const events = Layer.mock(EventV2.Service)({
publish: (definition, data) =>
Effect.succeed({ id: EventV2.ID.make("evt_reference_sync"), type: definition.type, data }),
})
const layer = CoreReference.layer.pipe(
Layer.provide(cache),
Layer.provide(events),
Layer.provide(Global.defaultLayer),
)
const result = await Effect.runPromise(
Effect.gen(function* () {
yield* Reference.sync({
references: {
docs: {
path: "./docs",
description: "Internal documentation",
hidden: true,
},
},
directory: "/workspace/src",
worktree: "/workspace",
})
return yield* (yield* CoreReference.Service).list()
}).pipe(Effect.provide(layer), Effect.scoped),
)
expect(result).toEqual([
expect.objectContaining({
name: "docs",
path: path.resolve("/workspace", "docs"),
description: "Internal documentation",
hidden: true,
source: expect.objectContaining({ description: "Internal documentation", hidden: true }),
}),
])
})
})
@@ -20,10 +20,21 @@ const kilo = [
]
test("internal TUI registry preserves every Kilo plugin before upstream builtins", () => {
const ids = internalTuiPlugins({ experimentalEventSystem: false }).map((plugin) => plugin.id)
const ids = internalTuiPlugins({ experimentalEventSystem: false, experimentalSessionSwitcher: false }).map(
(plugin) => plugin.id,
)
expect(ids.slice(0, kilo.length)).toEqual(kilo)
expect(new Set(ids).size).toBe(ids.length)
expect(ids).toContain("internal:sidebar-context")
expect(ids).toContain("diff-viewer")
})
test("experimental Kilo TUI plugins remain wired", () => {
const ids = internalTuiPlugins({ experimentalEventSystem: true, experimentalSessionSwitcher: true }).map(
(plugin) => plugin.id,
)
expect(ids).toContain("internal:session-v2-debug")
expect(ids).toContain("internal:session-switcher")
})
@@ -59,4 +59,82 @@ describe("reference HttpApi", () => {
},
])
})
// kilocode_change start - reference reads must reconcile config changes after instance disposal.
test("refreshes references after project config updates", async () => {
await using tmp = await tmpdir({
config: {
formatter: false,
lsp: false,
references: { docs: "./docs" },
},
})
const headers = { "content-type": "application/json", "x-kilo-directory": tmp.path }
const initial = await Server.Default().app.request("/api/reference", { headers })
expect(initial.status).toBe(200)
expect((await initial.json()).data[0].path).toBe(path.join(tmp.path, "docs"))
const updated = await Server.Default().app.request("/config", {
method: "PATCH",
headers,
body: JSON.stringify({
formatter: false,
lsp: false,
references: { docs: { path: "./updated", description: "Updated documentation" } },
}),
})
expect(updated.status).toBe(200)
const refreshed = await Server.Default().app.request("/api/reference", { headers })
expect(refreshed.status).toBe(200)
expect((await refreshed.json()).data[0]).toMatchObject({
name: "docs",
path: path.join(tmp.path, "updated"),
description: "Updated documentation",
})
})
// kilocode_change end
// kilocode_change start - direct clients must observe effective Kilo config before Agent initialization.
test("lists KILO_CONFIG_CONTENT references with metadata on the first request", async () => {
const previous = process.env.KILO_CONFIG_CONTENT
process.env.KILO_CONFIG_CONTENT = JSON.stringify({
references: {
private: {
path: "./private-docs",
description: "Private documentation",
hidden: true,
},
},
})
try {
await using tmp = await tmpdir({ config: { formatter: false, lsp: false } })
const response = await Server.Default().app.request("/api/reference", {
headers: { "x-kilo-directory": tmp.path },
})
expect(response.status).toBe(200)
const body = await response.json()
expect(body.data).toEqual([
{
name: "private",
path: path.join(tmp.path, "private-docs"),
description: "Private documentation",
hidden: true,
source: {
type: "local",
path: path.join(tmp.path, "private-docs"),
description: "Private documentation",
hidden: true,
},
},
])
} finally {
if (previous === undefined) delete process.env.KILO_CONFIG_CONTENT
else process.env.KILO_CONFIG_CONTENT = previous
}
})
// kilocode_change end
})
+4 -1
View File
@@ -2,7 +2,10 @@ import { Reference } from "@opencode-ai/core/reference"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { Api } from "../api"
import { response } from "../groups/location"
import { reconcile } from "../kilocode/reference-reconciler" // kilocode_change
export const ReferenceHandler = HttpApiBuilder.group(Api, "server.reference", (handlers) =>
handlers.handle("reference.list", () => response(Reference.Service.use((reference) => reference.list()))),
handlers.handle("reference.list", () =>
response(reconcile(Reference.Service.use((reference) => reference.list()))), // kilocode_change
),
)
@@ -0,0 +1,14 @@
import { Location } from "@opencode-ai/core/location"
import { PluginBoot } from "@opencode-ai/core/plugin/boot"
import { Reference } from "@opencode-ai/core/reference"
import { Context, Effect } from "effect"
export const ReferenceReconciler = Context.Reference<
Effect.Effect<void, never, Location.Service | PluginBoot.Service | Reference.Service>
>("@kilocode/ReferenceReconciler", {
defaultValue: () => Effect.void,
})
export function reconcile<A, E, R>(effect: Effect.Effect<A, E, R>) {
return Effect.flatMap(ReferenceReconciler, (reconciler) => Effect.andThen(reconciler, effect))
}
+12 -7
View File
@@ -74,8 +74,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
const event = useEvent()
const sdk = useSDK()
// kilocode_change - serialize message hydration per session
const syncing = new Map<string, Promise<void>>()
const syncing = new Map<string, Promise<void>>() // kilocode_change
const [defaultLocation, setDefaultLocation] = createSignal<LocationRef>({
directory: sdk.directory ?? process.cwd(),
})
@@ -124,6 +123,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
},
}
// kilocode_change start
const apply = (
event: Event,
metadata: {
@@ -131,7 +131,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
workspace: string | undefined
},
) => {
// kilocode_change
// kilocode_change end
switch (event.type) {
case "session.next.agent.switched":
message.update(event.properties.sessionID, (draft) => {
@@ -419,16 +419,21 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
case "session.next.compaction.delta":
break
case "session.next.compaction.ended":
// kilocode_change start - legacy v1 compaction events do not carry a projectable message identity.
if (!event.properties.messageID || !event.properties.reason) break
const id = event.properties.messageID
const reason = event.properties.reason
message.update(event.properties.sessionID, (draft) => {
message.prepend(draft, {
id: event.properties.messageID,
id,
type: "compaction",
reason: event.properties.reason,
reason,
summary: event.properties.text,
recent: event.properties.recent,
recent: event.properties.recent ?? "",
time: { created: event.properties.timestamp },
})
})
// kilocode_change end
break
case "reference.updated":
void result.location.reference.refresh()
@@ -442,7 +447,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
void result.location.connector.refresh({ directory: metadata.directory, workspaceID: metadata.workspace })
break
}
}
} // kilocode_change
// kilocode_change start - project live V2 session events into the hydrated message store
event.subscribe((event, metadata) => {