Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
7aec846
feat(palette): add a global command palette to the workbench
BISHRAWY Aug 16, 2026
a1d374f
fix(runtime): restore SQLite thread page counts
XingYu-Zhong Aug 16, 2026
e994fe0
fix(windows): skip unsupported POSIX chmod
XingYu-Zhong Aug 16, 2026
ad9d587
fix(installer): recreate desktop shortcut after updates
XingYu-Zhong Aug 16, 2026
11e1b1d
fix(chat): keep branch picker above scroll containers
XingYu-Zhong Aug 16, 2026
e554b56
fix(runtime): reject recycled PID lock owners
XingYu-Zhong Aug 16, 2026
4828cf1
fix(runtime): fence JSONL compaction against appends
XingYu-Zhong Aug 16, 2026
6d03732
docs(release): add v0.3.5 release notes
XingYu-Zhong Aug 16, 2026
fa0dea8
fix(i18n): localize advanced gateway settings
XingYu-Zhong Aug 16, 2026
be4c77b
Merge pull request #1190 from KunAgent/codex/fix-jsonl-compaction-1185
XingYu-Zhong Aug 16, 2026
94b4a1e
Merge pull request #1191 from KunAgent/codex/fix-process-identity-1186
XingYu-Zhong Aug 16, 2026
b91ff47
Merge pull request #1192 from KunAgent/codex/fix-hybrid-index-count-1187
XingYu-Zhong Aug 16, 2026
e814b53
Merge pull request #1193 from KunAgent/codex/fix-windows-chmod-1188
XingYu-Zhong Aug 16, 2026
fd50dac
Merge pull request #1194 from KunAgent/codex/fix-updater-desktop-shor…
XingYu-Zhong Aug 16, 2026
44d952e
Merge pull request #1195 from KunAgent/codex/fix-git-branch-picker-1189
XingYu-Zhong Aug 16, 2026
66baefb
Merge pull request #1197 from KunAgent/codex/fix-provider-gateway-i18n
XingYu-Zhong Aug 16, 2026
37b3bed
fix(chat): preserve subagent process navigation
XingYu-Zhong Aug 15, 2026
f71f5bb
fix(palette): bound conversation content search
XingYu-Zhong Aug 16, 2026
50f5668
Merge remote-tracking branch 'origin/develop' into codex/pr-1181-dead…
XingYu-Zhong Aug 16, 2026
e2bc2b6
refactor(runtime): split session usage compaction
XingYu-Zhong Aug 16, 2026
54ebfe7
Merge pull request #1196 from KunAgent/codex/release-notes-v0.3.5
XingYu-Zhong Aug 16, 2026
384dc39
Merge pull request #1181 from MohamedWaelBishr/feat/gui-command-palette
XingYu-Zhong Aug 16, 2026
78604b8
docs(release): add v0.3.5 release notes
XingYu-Zhong Aug 16, 2026
ecdf468
Merge pull request #1198 from KunAgent/codex/update-release-notes-v0.3.5
XingYu-Zhong Aug 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions build/installer.nsh
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,12 @@ Var /GLOBAL KunInstallerStopDiagnosticPath
Quit
${endif}

${if} ${isUpdated}
# electron-builder keeps existing shortcuts during --updated installs, but
# a scope/directory migration may already have removed the old link.
!insertmacro addDesktopLink "false"
${endif}

${if} $KunInstallerInPlaceUpdate == 1
!insertmacro kunRunMigrationHelper CleanupInPlaceLeftovers
${if} $KunInstallerHelperExitCode != 0
Expand Down
9 changes: 5 additions & 4 deletions kun/src/adapters/file/atomic-write.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { mkdir, rename, rm, writeFile } from 'node:fs/promises'
import { dirname } from 'node:path'

export type AtomicWriteFileOptions = {
allowDirectWriteFallback?: boolean
renameRetry?: {
attempts?: number
baseDelayMs?: number
Expand All @@ -23,9 +24,9 @@ export async function atomicWriteFile(
try {
await writeFile(tmp, contents, { encoding: 'utf-8', mode: 0o600 })
try {
await renameWithRetry(tmp, path, options.renameRetry)
await renameFileWithRetry(tmp, path, options.renameRetry)
} catch (error) {
if (!shouldFallbackToDirectWrite(error)) {
if (options.allowDirectWriteFallback === false || !shouldFallbackToDirectWrite(error)) {
throw error
}
await writeFile(path, contents, { encoding: 'utf-8', mode: 0o600 })
Expand Down Expand Up @@ -61,10 +62,10 @@ function describeAtomicWriteError(path: string, error: unknown): unknown {
return prefixed
}

async function renameWithRetry(
export async function renameFileWithRetry(
from: string,
to: string,
options: NonNullable<AtomicWriteFileOptions['renameRetry']> | undefined
options?: NonNullable<AtomicWriteFileOptions['renameRetry']>
): Promise<void> {
const attempts = Math.max(1, Math.floor(options?.attempts ?? DEFAULT_RENAME_RETRY_ATTEMPTS))
const baseDelayMs = Math.max(0, Math.floor(options?.baseDelayMs ?? DEFAULT_RENAME_RETRY_BASE_DELAY_MS))
Expand Down
45 changes: 33 additions & 12 deletions kun/src/adapters/file/file-session-jsonl.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { randomUUID } from 'node:crypto'
import { createReadStream, createWriteStream } from 'node:fs'
import { rename, rm, type FileHandle } from 'node:fs/promises'
import { rm, type FileHandle } from 'node:fs/promises'
import type { RuntimeEvent } from '../../contracts/events.js'
import { isPublicTurnItem, type TurnItem } from '../../contracts/items.js'
import type { ItemHistoryPage, ItemHistoryPageOptions } from '../../ports/session-store.js'
import { buildPublicItemHistoryPage, timelineSafeItem } from '../../services/item-history-page.js'
import { yieldToEventLoop } from '../hybrid/hybrid-thread-support.js'
import { renameFileWithRetry } from './atomic-write.js'

const MS_PER_DAY = 86_400_000
const DEFAULT_ITEM_HISTORY_MAX_RECORD_BYTES = 16 * 1024 * 1024
Expand Down Expand Up @@ -34,7 +35,12 @@ export function compactUsageEvents(
*/
export async function compactUsageEventsJsonlFile(
path: string,
options: { nowIso: string; retentionDays: number; maxRecordBytes: number }
options: {
nowIso: string
retentionDays: number
maxRecordBytes: number
commitReplacement?: (replace: () => Promise<void>) => Promise<boolean>
}
): Promise<boolean> {
const cutoffMs = Date.parse(options.nowIso) - options.retentionDays * MS_PER_DAY
if (!Number.isFinite(cutoffMs)) return false
Expand All @@ -57,11 +63,17 @@ export async function compactUsageEventsJsonlFile(
maxRecordBytes: options.maxRecordBytes,
keepLine: (event, index) => event.kind !== 'usage' || keepUsageIndexes.has(index)
})
await rename(tmp, path)
const replace = async (): Promise<void> => renameFileWithRetry(tmp, path)
if (options.commitReplacement) {
const committed = await options.commitReplacement(replace)
return committed
}
await replace()
return true
} catch (error) {
} finally {
// A stale snapshot deliberately declines replacement. Always remove its
// prepared rewrite; after a successful rename this is a harmless no-op.
await rm(tmp, { force: true }).catch(() => undefined)
throw error
}
}

Expand Down Expand Up @@ -225,7 +237,12 @@ export async function readLatestItemsFromJsonl(
maxRecordBytes?: number
rejectMalformed?: boolean
} = {}
): Promise<{ items: TurnItem[]; rawCount: number; malformedCount: number }> {
): Promise<{
items: TurnItem[]
rawCount: number
malformedCount: number
incompleteTrailingRecord: boolean
}> {
const maxRecordBytes = Math.max(
1,
Math.floor(options.maxRecordBytes ?? DEFAULT_ITEM_HISTORY_MAX_RECORD_BYTES)
Expand All @@ -235,9 +252,10 @@ export async function readLatestItemsFromJsonl(
let remainder = ''
let rawCount = 0
let malformedCount = 0
let incompleteTrailingRecord = false
let linesSinceYield = 0

const acceptLine = async (line: string): Promise<void> => {
const acceptLine = async (line: string, trailing = false): Promise<void> => {
if (!line.trim()) return
if (Buffer.byteLength(line, 'utf-8') > maxRecordBytes) {
throw new Error(`item history record exceeds ${maxRecordBytes} bytes`)
Expand All @@ -252,7 +270,8 @@ export async function readLatestItemsFromJsonl(
if (!latestById.has(item.id)) firstSeenIds.push(item.id)
latestById.set(item.id, item)
} catch {
malformedCount += 1
if (trailing) incompleteTrailingRecord = true
else malformedCount += 1
}
linesSinceYield += 1
if (linesSinceYield >= YIELD_EVERY_LINES) {
Expand All @@ -278,18 +297,20 @@ export async function readLatestItemsFromJsonl(
throw new Error(`item history record exceeds ${maxRecordBytes} bytes`)
}
}
await acceptLine(remainder)
await acceptLine(remainder, true)
} catch (error) {
if ((error as { code?: string }).code !== 'ENOENT') throw error
}

if (options.rejectMalformed && malformedCount > 0) {
throw new Error(`item history contains ${malformedCount} malformed record(s)`)
const rejectedRecords = malformedCount + (incompleteTrailingRecord ? 1 : 0)
if (options.rejectMalformed && rejectedRecords > 0) {
throw new Error(`item history contains ${rejectedRecords} malformed record(s)`)
}
return {
items: firstSeenIds.map((id) => latestById.get(id)!),
rawCount,
malformedCount
malformedCount,
incompleteTrailingRecord
}
}

Expand Down
28 changes: 27 additions & 1 deletion kun/src/adapters/file/file-session-store.ordering.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { makeAssistantTextItem, makeToolCallItem, makeToolResultItem, makeUserItem } from '../../domain/item.js'
import { FileSessionStore } from './file-session-store.js'
import { FileSessionStore, readLatestItemsFromJsonl } from './file-session-store.js'

const roots: string[] = []

Expand Down Expand Up @@ -143,6 +143,32 @@ describe('FileSessionStore item ordering', () => {
expect(await readFile(path, 'utf-8')).toBe(before)
})

it('distinguishes an unterminated trailing write from a malformed completed row', async () => {
const root = await mkdtemp(join(tmpdir(), 'kun-session-incomplete-tail-'))
roots.push(root)
const path = join(root, 'messages.jsonl')
const item = makeUserItem({
id: 'user_1',
threadId: 'thread_incomplete_tail',
turnId: 'turn_1',
text: 'valid'
})
await appendFile(path, `${JSON.stringify(item)}\n{"id":`)

await expect(readLatestItemsFromJsonl(path)).resolves.toMatchObject({
items: [expect.objectContaining({ id: 'user_1' })],
rawCount: 1,
malformedCount: 0,
incompleteTrailingRecord: true
})

await appendFile(path, '}\n{broken-json\n')
await expect(readLatestItemsFromJsonl(path)).resolves.toMatchObject({
malformedCount: 2,
incompleteTrailingRecord: false
})
})

it('does not retain a Session item array that exceeds its byte admission limit', async () => {
const root = await mkdtemp(join(tmpdir(), 'kun-session-cache-budget-'))
roots.push(root)
Expand Down
134 changes: 134 additions & 0 deletions kun/src/adapters/file/file-session-store.search.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import { mkdtemp, rm, stat } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { makeAssistantTextItem, makeToolResultItem, makeUserItem } from '../../domain/item.js'
import { FileSessionStore } from './file-session-store.js'

const roots: string[] = []

afterEach(async () => {
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
})

async function newStore(options: { itemHistoryCompactionMinBytes?: number } = {}): Promise<{
store: FileSessionStore
root: string
messagesPath: (threadId: string) => string
}> {
const root = await mkdtemp(join(tmpdir(), 'kun-session-search-'))
roots.push(root)
return {
store: new FileSessionStore({ dataDir: root, ...options }),
root,
messagesPath: (threadId) => join(root, 'threads', threadId, 'messages.jsonl')
}
}

describe('FileSessionStore.searchItemText', () => {
it('finds user and assistant text and returns the matching item text', async () => {
const { store } = await newStore()
const threadId = 'thread_search'
await store.appendItem(threadId, makeUserItem({
id: 'i1', turnId: 't1', threadId, text: 'Please rework the checkout flow.'
}))
await store.appendItem(threadId, makeAssistantTextItem({
id: 'i2', turnId: 't1', threadId, text: 'Rewriting the billing module now.'
}))

await expect(store.searchItemText(threadId, 'checkout'))
.resolves.toBe('Please rework the checkout flow.')
await expect(store.searchItemText(threadId, 'BILLING'))
.resolves.toBe('Rewriting the billing module now.')
await expect(store.searchItemText(threadId, 'absent')).resolves.toBeNull()
})

it('ignores tool payloads so search never surfaces raw tool output', async () => {
const { store } = await newStore()
const threadId = 'thread_tools'
await store.appendItem(threadId, makeToolResultItem({
id: 'i1', turnId: 't1', threadId, callId: 'c1', toolName: 'read', output: { text: 'secret-token' }
}))
await expect(store.searchItemText(threadId, 'secret-token')).resolves.toBeNull()
})

it('does not match on record metadata that only looks like a hit', async () => {
const { store } = await newStore()
const threadId = 'thread_meta'
await store.appendItem(threadId, makeUserItem({
id: 'i1', turnId: 't1', threadId, text: 'unrelated body'
}))
// 'assistant_text' and the thread id appear in the raw JSON of every
// record; only real item text may produce a match.
await expect(store.searchItemText(threadId, 'user_message')).resolves.toBeNull()
await expect(store.searchItemText(threadId, 'thread_meta')).resolves.toBeNull()
})

it('never schedules a rewrite the way loadItems does', async () => {
// A one-byte threshold makes every log "oversized".
const { store, messagesPath } = await newStore({ itemHistoryCompactionMinBytes: 1 })
const threadId = 'thread_big'
await store.appendItem(threadId, makeUserItem({
id: 'i1', turnId: 't1', threadId, text: 'first checkout note'
}))
await store.appendItem(threadId, makeUserItem({
id: 'i1', turnId: 't1', threadId, text: 'second checkout note'
}))
await store.resetMemory()
const path = messagesPath(threadId)
const before = (await stat(path)).size

// Searching leaves the log untouched, and queues no deferred rewrite.
await expect(store.searchItemText(threadId, 'checkout')).resolves.toContain('checkout')
await store.flushScheduledCompaction(threadId)
expect((await stat(path)).size).toBe(before)

// The blocking path schedules the rewrite; this documents the contrast.
await store.loadItems(threadId)
await store.flushScheduledCompaction(threadId)
expect((await stat(path)).size).toBeLessThan(before)
})

it('reads the tail when a log exceeds the scan window', async () => {
const { store } = await newStore()
const threadId = 'thread_tail'
const filler = 'x'.repeat(4_000)
await store.appendItem(threadId, makeUserItem({
id: 'oldest', turnId: 't0', threadId, text: 'oldest-marker ' + filler
}))
for (let index = 0; index < 40; index += 1) {
await store.appendItem(threadId, makeUserItem({
id: 'mid_' + index, turnId: 't1', threadId, text: 'filler ' + filler
}))
}
await store.appendItem(threadId, makeUserItem({
id: 'recent', turnId: 't2', threadId, text: 'recent-marker at the end'
}))
await store.resetMemory()

await expect(store.searchItemText(threadId, 'recent-marker', { maxBytes: 8_000 }))
.resolves.toBe('recent-marker at the end')
// Content older than the tail window is outside the bound by design.
await expect(store.searchItemText(threadId, 'oldest-marker', { maxBytes: 8_000 }))
.resolves.toBeNull()
// Widening the window brings it back into range.
await expect(store.searchItemText(threadId, 'oldest-marker', { maxBytes: 4_000_000 }))
.resolves.toContain('oldest-marker')
})

it('returns null for unsafe thread ids and empty queries', async () => {
const { store } = await newStore()
await expect(store.searchItemText('../escape', 'anything')).resolves.toBeNull()
await expect(store.searchItemText('thread_ok', '')).resolves.toBeNull()
})

it('does not start or return a scan after its deadline', async () => {
const { store } = await newStore()
const threadId = 'thread_expired'
await store.appendItem(threadId, makeUserItem({
id: 'i1', turnId: 't1', threadId, text: 'checkout after deadline'
}))
await expect(store.searchItemText(threadId, 'checkout', { deadlineAtMs: Date.now() - 1 }))
.resolves.toBeNull()
})
})
Loading
Loading