mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-28 19:11:03 +08:00
fix: address third-pass upstream merge review
This commit is contained in:
+29
-16
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
),
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
}),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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* () {
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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),
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
])
|
||||
|
||||
|
||||
@@ -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")
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -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
|
||||
},
|
||||
})
|
||||
@@ -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 }),
|
||||
),
|
||||
)
|
||||
})
|
||||
}),
|
||||
)
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user