From 042ffced8dad36dbdd5993c7ae11acb7d190624b Mon Sep 17 00:00:00 2001 From: weishu Date: Mon, 27 Jul 2026 10:19:25 +0800 Subject: [PATCH] fix(kimi): sync native local session titles --- cli/src/agent/acpSessionTitle.test.ts | 1 + cli/src/agent/acpSessionTitle.ts | 34 ++-- cli/src/agent/nativeSessionTitle.ts | 52 ++++++ cli/src/api/apiSession.ts | 4 + cli/src/kimi/kimiLocalLauncher.test.ts | 150 ++++++++++++++++++ cli/src/kimi/kimiLocalLauncher.ts | 44 ++++- .../utils/kimiSessionTitleWatcher.test.ts | 73 +++++++++ cli/src/kimi/utils/kimiSessionTitleWatcher.ts | 92 +++++++++++ cli/src/kimi/utils/kimiWireLocator.test.ts | 2 + cli/src/kimi/utils/kimiWireLocator.ts | 12 +- 10 files changed, 440 insertions(+), 24 deletions(-) create mode 100644 cli/src/agent/nativeSessionTitle.ts create mode 100644 cli/src/kimi/kimiLocalLauncher.test.ts create mode 100644 cli/src/kimi/utils/kimiSessionTitleWatcher.test.ts create mode 100644 cli/src/kimi/utils/kimiSessionTitleWatcher.ts diff --git a/cli/src/agent/acpSessionTitle.test.ts b/cli/src/agent/acpSessionTitle.test.ts index c846d3f6..d2118ebe 100644 --- a/cli/src/agent/acpSessionTitle.test.ts +++ b/cli/src/agent/acpSessionTitle.test.ts @@ -19,6 +19,7 @@ describe('registerAcpSessionTitleSync', () => { listener!({ sessionId: 'session-1', title: '' }); listener!({ sessionId: 'session-1', title: null }); listener!({ sessionId: 'session-1', title: 'Untitled' }); + listener!({ sessionId: 'session-1', title: 'New Session' }); listener!({ sessionId: 'session-1', title: 'New session - 2026-07-12T15:30:03.251Z' }); expect(sendClaudeSessionMessage).toHaveBeenCalledTimes(1); diff --git a/cli/src/agent/acpSessionTitle.ts b/cli/src/agent/acpSessionTitle.ts index 04b6bd9a..f24d447f 100644 --- a/cli/src/agent/acpSessionTitle.ts +++ b/cli/src/agent/acpSessionTitle.ts @@ -1,28 +1,18 @@ import { randomUUID } from 'node:crypto'; import type { ApiSessionClient } from '@/api/apiSession'; import type { AcpSdkBackend } from '@/agent/backends/acp'; +import { normalizeNativeSessionTitle } from '@/agent/nativeSessionTitle'; type AcpSessionTitleBackend = Pick; type AcpSessionTitleClient = Pick; -function isPlaceholderTitle(title: string): boolean { - return title === 'Untitled' - || /^(?:New|Child) session - \d{4}-\d{2}-\d{2}T/.test(title); -} - -/** Syncs agent-generated ACP session titles into HAPI session metadata. */ -export function registerAcpSessionTitleSync( - backend: AcpSessionTitleBackend, - client: AcpSessionTitleClient -): void { +/** Creates a normalized, deduplicated native-title sink for a HAPI session. */ +function createSessionTitleSync(client: AcpSessionTitleClient): (title: unknown) => void { let lastTitle: string | null = null; - backend.setSessionInfoUpdateListener(({ title }) => { - if (typeof title !== 'string') { - return; - } - const normalizedTitle = title.trim(); - if (!normalizedTitle || isPlaceholderTitle(normalizedTitle) || normalizedTitle === lastTitle) { + return (title) => { + const normalizedTitle = normalizeNativeSessionTitle(title); + if (!normalizedTitle || normalizedTitle === lastTitle) { return; } lastTitle = normalizedTitle; @@ -31,5 +21,17 @@ export function registerAcpSessionTitleSync( summary: normalizedTitle, leafUuid: randomUUID() }); + }; +} + +/** Syncs agent-generated ACP session titles into HAPI session metadata. */ +export function registerAcpSessionTitleSync( + backend: AcpSessionTitleBackend, + client: AcpSessionTitleClient +): void { + const syncTitle = createSessionTitleSync(client); + + backend.setSessionInfoUpdateListener(({ title }) => { + syncTitle(title); }); } diff --git a/cli/src/agent/nativeSessionTitle.ts b/cli/src/agent/nativeSessionTitle.ts new file mode 100644 index 00000000..be00df19 --- /dev/null +++ b/cli/src/agent/nativeSessionTitle.ts @@ -0,0 +1,52 @@ +import type { ApiSessionClient } from '@/api/apiSession'; +import type { Metadata } from '@/api/types'; + +type NativeSessionTitleMetadataClient = Pick; + +export function normalizeNativeSessionTitle(title: unknown): string | null { + if (typeof title !== 'string') { + return null; + } + const normalizedTitle = title.trim(); + if (!normalizedTitle + || /^(?:Untitled|New Session)$/i.test(normalizedTitle) + || /^(?:New|Child) session - \d{4}-\d{2}-\d{2}T/i.test(normalizedTitle)) { + return null; + } + return normalizedTitle; +} + +/** Syncs a native agent title into HAPI metadata without creating a chat row. */ +export function createNativeSessionTitleMetadataSync( + client: NativeSessionTitleMetadataClient +): (title: unknown) => void { + let lastTitle: string | null = null; + + return (title) => { + const normalizedTitle = normalizeNativeSessionTitle(title); + if (!normalizedTitle || normalizedTitle === lastTitle) { + return; + } + lastTitle = normalizedTitle; + + if (metadataHasTitle(client.getMetadata(), normalizedTitle)) { + return; + } + client.updateMetadata((metadata) => { + if (metadataHasTitle(metadata, normalizedTitle)) { + return metadata; + } + return { + ...metadata, + summary: { + text: normalizedTitle, + updatedAt: Date.now() + } + }; + }); + }; +} + +function metadataHasTitle(metadata: Readonly | null, title: string): boolean { + return metadata?.name?.trim() === title || metadata?.summary?.text.trim() === title; +} diff --git a/cli/src/api/apiSession.ts b/cli/src/api/apiSession.ts index 3e98814a..bf79df8d 100644 --- a/cli/src/api/apiSession.ts +++ b/cli/src/api/apiSession.ts @@ -423,6 +423,10 @@ export class ApiSessionClient extends EventEmitter { return this.state } + getMetadata(): Readonly | null { + return this.metadata + } + isPending(): boolean { return this.state === 'pending' || this.state === 'materializing' } diff --git a/cli/src/kimi/kimiLocalLauncher.test.ts b/cli/src/kimi/kimiLocalLauncher.test.ts new file mode 100644 index 00000000..4f1b7e13 --- /dev/null +++ b/cli/src/kimi/kimiLocalLauncher.test.ts @@ -0,0 +1,150 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { Metadata } from '@/api/types'; + +const harness = vi.hoisted(() => ({ + locatorOptions: null as null | { + onLocated: (located: { sessionId: string; wirePath: string; statePath: string }) => void; + }, + titleWatcherOptions: null as null | { + statePath: string; + onTitle: (title: string) => void; + }, + titlesToEmit: [] as string[], + locatorCleanup: vi.fn(async () => {}), + scannerCleanup: vi.fn(async () => {}), + titleWatcherCleanup: vi.fn(async () => {}) +})); + +vi.mock('./utils/kimiWireLocator', () => ({ + createKimiWireLocator: (options: typeof harness.locatorOptions) => { + harness.locatorOptions = options; + return { + ready: Promise.resolve(), + cleanup: harness.locatorCleanup + }; + } +})); + +vi.mock('./utils/kimiWireScanner', () => ({ + createKimiWireScanner: async () => ({ cleanup: harness.scannerCleanup }), + convertKimiWireEvent: () => null +})); + +vi.mock('./utils/kimiSessionTitleWatcher', () => ({ + createKimiSessionTitleWatcher: async (options: NonNullable) => { + harness.titleWatcherOptions = options; + for (const title of harness.titlesToEmit) { + options.onTitle(title); + } + return { cleanup: harness.titleWatcherCleanup }; + } +})); + +vi.mock('@/modules/common/launcher/BaseLocalLauncher', () => ({ + BaseLocalLauncher: class { + async run(): Promise<'exit'> { + harness.locatorOptions?.onLocated({ + sessionId: 'session_native', + wirePath: '/tmp/kimi/wire.jsonl', + statePath: '/tmp/kimi/state.json' + }); + await Promise.resolve(); + return 'exit'; + } + } +})); + +import { kimiLocalLauncher } from './kimiLocalLauncher'; + +describe('kimiLocalLauncher title sync', () => { + afterEach(() => { + harness.locatorOptions = null; + harness.titleWatcherOptions = null; + harness.titlesToEmit = []; + harness.locatorCleanup.mockClear(); + harness.scannerCleanup.mockClear(); + harness.titleWatcherCleanup.mockClear(); + }); + + it('syncs the native state title and cleans up both session watchers', async () => { + const sentMessages: unknown[] = []; + const foundSessionIds: string[] = []; + let metadata: Metadata = { path: '/tmp/workspace', host: 'localhost' }; + const updateMetadata = vi.fn((handler: (current: Metadata) => Metadata) => { + metadata = handler(metadata); + }); + harness.titlesToEmit = ['New Session', ' Native Kimi Title ', 'Native Kimi Title']; + const session = { + path: '/tmp/workspace', + sessionId: null, + startedBy: 'terminal' as const, + startingMode: 'local' as const, + queue: {}, + client: { + rpcHandlerManager: {}, + getMetadata: () => metadata, + updateMetadata, + sendClaudeSessionMessage: (message: unknown) => sentMessages.push(message) + }, + getPermissionMode: () => 'default', + onSessionFound: (sessionId: string) => foundSessionIds.push(sessionId), + sendUserMessage: () => {}, + sendAgentMessage: () => {}, + sendSessionEvent: () => {}, + recordLocalLaunchFailure: () => {} + }; + + await expect(kimiLocalLauncher(session as never, {})).resolves.toBe('exit'); + + expect(foundSessionIds).toEqual(['session_native']); + expect(harness.titleWatcherOptions?.statePath).toBe('/tmp/kimi/state.json'); + expect(sentMessages).toEqual([]); + expect(metadata).toEqual({ + path: '/tmp/workspace', + host: 'localhost', + summary: { + text: 'Native Kimi Title', + updatedAt: expect.any(Number) + } + }); + expect(updateMetadata).toHaveBeenCalledTimes(1); + expect(harness.locatorCleanup).toHaveBeenCalledTimes(1); + expect(harness.scannerCleanup).toHaveBeenCalledTimes(1); + expect(harness.titleWatcherCleanup).toHaveBeenCalledTimes(1); + }); + + it('does not update metadata when the resumed HAPI title already matches', async () => { + const metadata: Metadata = { + path: '/tmp/workspace', + host: 'localhost', + summary: { text: 'Native Kimi Title', updatedAt: 123 } + }; + const updateMetadata = vi.fn(); + const sendClaudeSessionMessage = vi.fn(); + harness.titlesToEmit = ['Native Kimi Title']; + const session = { + path: '/tmp/workspace', + sessionId: 'session_native', + startedBy: 'terminal' as const, + startingMode: 'local' as const, + queue: {}, + client: { + rpcHandlerManager: {}, + getMetadata: () => metadata, + updateMetadata, + sendClaudeSessionMessage + }, + getPermissionMode: () => 'default', + onSessionFound: () => {}, + sendUserMessage: () => {}, + sendAgentMessage: () => {}, + sendSessionEvent: () => {}, + recordLocalLaunchFailure: () => {} + }; + + await kimiLocalLauncher(session as never, {}); + + expect(updateMetadata).not.toHaveBeenCalled(); + expect(sendClaudeSessionMessage).not.toHaveBeenCalled(); + }); +}); diff --git a/cli/src/kimi/kimiLocalLauncher.ts b/cli/src/kimi/kimiLocalLauncher.ts index a7dff3b7..49912ba5 100644 --- a/cli/src/kimi/kimiLocalLauncher.ts +++ b/cli/src/kimi/kimiLocalLauncher.ts @@ -3,8 +3,10 @@ import { kimiLocal } from './kimiLocal'; import { KimiSession } from './session'; import type { PermissionMode } from './types'; import { BaseLocalLauncher } from '@/modules/common/launcher/BaseLocalLauncher'; +import { createNativeSessionTitleMetadataSync } from '@/agent/nativeSessionTitle'; import { createKimiWireLocator, type KimiWireLocator } from './utils/kimiWireLocator'; import { convertKimiWireEvent, createKimiWireScanner, type KimiWireScanner } from './utils/kimiWireScanner'; +import { createKimiSessionTitleWatcher, type KimiSessionTitleWatcher } from './utils/kimiSessionTitleWatcher'; function mapApprovalMode(mode: PermissionMode | undefined): { yolo: boolean; plan: boolean } { if (!mode || mode === 'default' || mode === 'read-only') { @@ -22,13 +24,15 @@ export async function kimiLocalLauncher( model?: string; } ): Promise<'switch' | 'exit'> { - // Local mode spawns the kimi TUI directly, so the only way to mirror the - // terminal conversation to hub/web is to watch the wire.jsonl journal the - // kimi-code process writes (same role as the codex transcript scanner). + // Local mode spawns the kimi TUI directly. Mirror conversation events from + // wire.jsonl and Kimi's authoritative native title from state.json. const startupTimestampMs = Date.now(); let shuttingDown = false; let scanner: KimiWireScanner | null = null; + let titleWatcher: KimiSessionTitleWatcher | null = null; let pendingScannerSetup: Promise | null = null; + let pendingTitleWatcherSetup: Promise | null = null; + const syncTitle = createNativeSessionTitleMetadataSync(session.client); const attachWireScanner = (wirePath: string): Promise => { const setup = (async () => { @@ -65,19 +69,41 @@ export async function kimiLocalLauncher( return pendingScannerSetup; }; + const attachTitleWatcher = (statePath: string): Promise => { + const setup = (async () => { + const created = await createKimiSessionTitleWatcher({ + statePath, + onTitle: syncTitle + }); + if (shuttingDown) { + await created.cleanup(); + return; + } + titleWatcher = created; + logger.debug(`[kimi-local]: Attached title watcher to ${statePath}`); + })(); + pendingTitleWatcherSetup = setup.catch((error) => { + logger.warn(`[kimi-local]: Title watcher setup failed for ${statePath}`, error); + }).finally(() => { + pendingTitleWatcherSetup = null; + }); + return pendingTitleWatcherSetup; + }; + const locator: KimiWireLocator = createKimiWireLocator({ cwd: session.path, startupTimestampMs, resumeSessionId: session.sessionId, - onLocated: ({ sessionId, wirePath }) => { + onLocated: ({ sessionId, wirePath, statePath }) => { if (shuttingDown) { return; } session.onSessionFound(sessionId); void attachWireScanner(wirePath); + void attachTitleWatcher(statePath); }, onAmbiguous: (sessionIds) => { - logger.warn(`[kimi-local]: Multiple fresh kimi sessions found (${sessionIds.join(', ')}); transcript sync disabled for this launch`); + logger.warn(`[kimi-local]: Multiple fresh kimi sessions found (${sessionIds.join(', ')}); session sync disabled for this launch`); } }); @@ -119,10 +145,18 @@ export async function kimiLocalLauncher( if (pendingScannerSetup) { await pendingScannerSetup; } + if (pendingTitleWatcherSetup) { + await pendingTitleWatcherSetup; + } const activeScanner = scanner as KimiWireScanner | null; if (activeScanner) { await activeScanner.cleanup(); scanner = null; } + const activeTitleWatcher = titleWatcher as KimiSessionTitleWatcher | null; + if (activeTitleWatcher) { + await activeTitleWatcher.cleanup(); + titleWatcher = null; + } } } diff --git a/cli/src/kimi/utils/kimiSessionTitleWatcher.test.ts b/cli/src/kimi/utils/kimiSessionTitleWatcher.test.ts new file mode 100644 index 00000000..dc3aff76 --- /dev/null +++ b/cli/src/kimi/utils/kimiSessionTitleWatcher.test.ts @@ -0,0 +1,73 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mkdir, rm, writeFile } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { createKimiSessionTitleWatcher, type KimiSessionTitleWatcher } from './kimiSessionTitleWatcher'; + +describe('kimiSessionTitleWatcher', () => { + let testDir: string; + let statePath: string; + let watcher: KimiSessionTitleWatcher | null = null; + + beforeEach(async () => { + testDir = join(tmpdir(), `kimi-title-${Date.now()}-${Math.random().toString(36).slice(2)}`); + statePath = join(testDir, 'state.json'); + await mkdir(testDir, { recursive: true }); + }); + + afterEach(async () => { + if (watcher) { + await watcher.cleanup(); + watcher = null; + } + if (existsSync(testDir)) { + await rm(testDir, { recursive: true, force: true }); + } + }); + + it('emits an existing resume title immediately and later title changes once', async () => { + await writeFile(statePath, JSON.stringify({ title: 'Existing title' })); + const titles: string[] = []; + + watcher = await createKimiSessionTitleWatcher({ + statePath, + intervalMs: 20, + onTitle: (title) => titles.push(title) + }); + + expect(titles).toEqual(['Existing title']); + + await writeFile(statePath, '{'); + await new Promise((resolve) => setTimeout(resolve, 60)); + expect(titles).toEqual(['Existing title']); + + await writeFile(statePath, JSON.stringify({ title: 'Renamed title' })); + await vi.waitFor(() => expect(titles).toEqual(['Existing title', 'Renamed title'])); + + await writeFile(statePath, JSON.stringify({ title: 'Renamed title', updatedAt: Date.now() })); + await new Promise((resolve) => setTimeout(resolve, 60)); + expect(titles).toEqual(['Existing title', 'Renamed title']); + }); + + it('detects a title when a new session writes state later and stops after cleanup', async () => { + const titles: string[] = []; + watcher = await createKimiSessionTitleWatcher({ + statePath, + intervalMs: 20, + onTitle: (title) => titles.push(title) + }); + + await writeFile(statePath, JSON.stringify({ title: 'New Session' })); + await vi.waitFor(() => expect(titles).toEqual(['New Session'])); + + await writeFile(statePath, JSON.stringify({ title: 'First prompt title' })); + await vi.waitFor(() => expect(titles).toEqual(['New Session', 'First prompt title'])); + + await watcher.cleanup(); + watcher = null; + await writeFile(statePath, JSON.stringify({ title: 'After cleanup' })); + await new Promise((resolve) => setTimeout(resolve, 60)); + expect(titles).toEqual(['New Session', 'First prompt title']); + }); +}); diff --git a/cli/src/kimi/utils/kimiSessionTitleWatcher.ts b/cli/src/kimi/utils/kimiSessionTitleWatcher.ts new file mode 100644 index 00000000..64b8179e --- /dev/null +++ b/cli/src/kimi/utils/kimiSessionTitleWatcher.ts @@ -0,0 +1,92 @@ +import { readFile } from 'node:fs/promises'; + +const DEFAULT_TITLE_WATCH_INTERVAL_MS = 500; + +export type KimiSessionTitleWatcher = { + cleanup: () => Promise; +}; + +type KimiSessionTitleWatcherOptions = { + statePath: string; + onTitle: (title: string) => void; + intervalMs?: number; +}; + +/** Polls Kimi's authoritative state.json title, including atomic replacements. */ +export async function createKimiSessionTitleWatcher( + options: KimiSessionTitleWatcherOptions +): Promise { + const watcher = new KimiSessionTitleWatcherImpl(options); + await watcher.start(); + return { + cleanup: async () => { + await watcher.cleanup(); + } + }; +} + +class KimiSessionTitleWatcherImpl { + private readonly statePath: string; + private readonly onTitle: KimiSessionTitleWatcherOptions['onTitle']; + private readonly intervalMs: number; + private interval: ReturnType | null = null; + private scanPromise: Promise | null = null; + private lastTitle: string | null = null; + private stopped = false; + + constructor(options: KimiSessionTitleWatcherOptions) { + this.statePath = options.statePath; + this.onTitle = options.onTitle; + this.intervalMs = options.intervalMs ?? DEFAULT_TITLE_WATCH_INTERVAL_MS; + } + + async start(): Promise { + await this.scan(); + if (this.stopped) { + return; + } + this.interval = setInterval(() => void this.scan(), this.intervalMs); + this.interval.unref?.(); + } + + async cleanup(): Promise { + this.stopped = true; + if (this.interval) { + clearInterval(this.interval); + this.interval = null; + } + await this.scanPromise?.catch(() => {}); + } + + private async scan(): Promise { + if (this.stopped || this.scanPromise) { + return this.scanPromise ?? Promise.resolve(); + } + this.scanPromise = this.readTitle(); + try { + await this.scanPromise; + } finally { + this.scanPromise = null; + } + } + + private async readTitle(): Promise { + try { + const raw = await readFile(this.statePath, 'utf8'); + const parsed = JSON.parse(raw) as unknown; + if (!isRecord(parsed) || typeof parsed.title !== 'string' || parsed.title === this.lastTitle) { + return; + } + this.lastTitle = parsed.title; + if (!this.stopped) { + this.onTitle(parsed.title); + } + } catch { + // state.json can be absent or temporarily invalid while Kimi writes it. + } + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} diff --git a/cli/src/kimi/utils/kimiWireLocator.test.ts b/cli/src/kimi/utils/kimiWireLocator.test.ts index 719af9b2..fc87a68e 100644 --- a/cli/src/kimi/utils/kimiWireLocator.test.ts +++ b/cli/src/kimi/utils/kimiWireLocator.test.ts @@ -6,6 +6,7 @@ import { tmpdir } from 'node:os'; import { createKimiWireLocator, encodeKimiWorkDirKey, + getKimiStatePath, getKimiWirePath, type LocatedKimiWire } from './kimiWireLocator'; @@ -74,6 +75,7 @@ describe('kimiWireLocator', () => { expect(located).toHaveLength(1); expect(located[0]?.sessionId).toBe('session_aaa-bbb'); expect(located[0]?.wirePath).toBe(getKimiWirePath(join(homeDir, 'sessions', encodeKimiWorkDirKey(workDir), 'session_aaa-bbb'))); + expect(located[0]?.statePath).toBe(getKimiStatePath(join(homeDir, 'sessions', encodeKimiWorkDirKey(workDir), 'session_aaa-bbb'))); } finally { await locator.cleanup(); } diff --git a/cli/src/kimi/utils/kimiWireLocator.ts b/cli/src/kimi/utils/kimiWireLocator.ts index 91e2e59f..0097be14 100644 --- a/cli/src/kimi/utils/kimiWireLocator.ts +++ b/cli/src/kimi/utils/kimiWireLocator.ts @@ -7,6 +7,7 @@ import { getKimiCodeHome } from './config'; export type LocatedKimiWire = { sessionId: string; wirePath: string; + statePath: string; }; export type KimiWireLocator = { @@ -63,10 +64,14 @@ export function getKimiWirePath(sessionDir: string): string { return join(sessionDir, 'agents', 'main', 'wire.jsonl'); } +export function getKimiStatePath(sessionDir: string): string { + return join(sessionDir, 'state.json'); +} + /** * Polls the kimi-code session storage for the session the locally spawned * `kimi` process just created in this working directory, and resolves with - * its wire transcript path. Mirrors the codex transcript locator: sessions + * its wire transcript and state paths. Mirrors the codex transcript locator: sessions * created before hapi's launch are ignored; multiple fresh candidates are * treated as ambiguous rather than attaching to the wrong session. */ @@ -202,6 +207,7 @@ class KimiWireLocatorImpl { const sessionDir = join(this.workspaceDir, entry.name); const wirePath = getKimiWirePath(sessionDir); + const statePath = getKimiStatePath(sessionDir); const wireStats = await stat(wirePath).catch(() => null); if (!wireStats || !wireStats.isFile()) { continue; @@ -223,14 +229,14 @@ class KimiWireLocatorImpl { } } - candidates.push({ sessionId: entry.name, wirePath }); + candidates.push({ sessionId: entry.name, wirePath, statePath }); } return candidates; } private async matchesWorkDir(sessionDir: string): Promise { try { - const raw = await readFile(join(sessionDir, 'state.json'), 'utf8'); + const raw = await readFile(getKimiStatePath(sessionDir), 'utf8'); const parsed = JSON.parse(raw) as { workDir?: unknown }; if (typeof parsed.workDir !== 'string' || parsed.workDir.length === 0) { return true;