Compare commits

...

3 Commits

Author SHA1 Message Date
shey db42bd2cff add live syncing 2026-03-16 15:55:13 -07:00
shey 744030be16 add tests 2026-03-10 15:23:47 -07:00
shey 8e1f6dfa41 init 2026-03-10 14:21:23 -07:00
11 changed files with 689 additions and 2 deletions
+15
View File
@@ -149,6 +149,7 @@ import { HelpPanelContent } from "./HelpPanelContent"
import { HighlightedInput } from "./HighlightedInput"
import { HistoryPanelContent } from "./HistoryPanelContent"
import { providerModels } from "./ModelPicker"
import { RulesPanelContent } from "./RulesPanelContent"
import { SettingsPanelContent } from "./SettingsPanelContent"
import { SkillsPanelContent } from "./SkillsPanelContent"
import { SlashCommandMenu } from "./SlashCommandMenu"
@@ -413,6 +414,7 @@ export const ChatView: React.FC<ChatViewProps> = ({
| { type: "settings"; initialMode?: "model-picker" | "featured-models"; initialModelKey?: "actModelId" | "planModelId" }
| { type: "history" }
| { type: "help" }
| { type: "rules" }
| { type: "skills" }
| null
>(null)
@@ -1158,6 +1160,14 @@ export const ChatView: React.FC<ChatViewProps> = ({
setSlashMenuDismissed(true)
return
}
if (cmd.name === "rules") {
setActivePanel({ type: "rules" })
setTextInput("")
setCursorPos(0)
setSelectedSlashIndex(0)
setSlashMenuDismissed(true)
return
}
if (cmd.name === "skills") {
setActivePanel({ type: "skills" })
setTextInput("")
@@ -1555,6 +1565,11 @@ export const ChatView: React.FC<ChatViewProps> = ({
{/* Help panel */}
{activePanel?.type === "help" && <HelpPanelContent onClose={() => setActivePanel(null)} />}
{/* Rules panel */}
{activePanel?.type === "rules" && ctrl && (
<RulesPanelContent controller={ctrl} onClose={() => setActivePanel(null)} />
)}
{/* Skills panel */}
{activePanel?.type === "skills" && ctrl && (
<SkillsPanelContent
+188
View File
@@ -0,0 +1,188 @@
import { Text } from "ink"
import { render } from "ink-testing-library"
import React from "react"
import { beforeEach, describe, expect, it, vi } from "vitest"
vi.mock("../context/TaskContext", () => ({
useTaskState: () => ({
clineMessages: [],
mode: "act",
}),
useTaskContext: () => ({
controller: null,
clearState: vi.fn(),
}),
}))
vi.mock("../hooks/useStateSubscriber", () => ({
useIsSpinnerActive: () => ({ isActive: false, startTime: null }),
}))
vi.mock("@/core/controller/slash/getAvailableSlashCommands", () => ({
getAvailableSlashCommands: vi.fn().mockResolvedValue({ commands: [] }),
}))
vi.mock("@/core/controller/task/showTaskWithId", () => ({
showTaskWithId: vi.fn(async () => {}),
}))
vi.mock("@/core/storage/StateManager", () => ({
StateManager: {
get: () => ({
getGlobalSettingsKey: vi.fn((key: string) => {
if (key === "mode") return "act"
if (key === "yoloModeToggled") return false
if (key === "actModeApiModelId") return "claude-sonnet-4-20250514"
return null
}),
getGlobalStateKey: vi.fn().mockReturnValue([]),
getApiConfiguration: vi.fn().mockReturnValue({}),
setGlobalState: vi.fn(),
}),
},
}))
vi.mock("@/services/telemetry", () => ({
telemetryService: {
captureHostEvent: vi.fn(),
},
}))
vi.mock("@shared/services/Session", () => ({
Session: {
get: () => ({
getStats: vi.fn().mockReturnValue({}),
}),
},
}))
vi.mock("child_process", () => ({
execSync: vi.fn().mockReturnValue(""),
exec: vi.fn(),
}))
vi.mock("./ActionButtons", () => ({
ActionButtons: () => React.createElement(Text, null, "ActionButtons"),
getButtonConfig: vi.fn(() => ({ enableButtons: false })),
}))
vi.mock("./AsciiMotionCli", () => ({
AsciiMotionCli: () => React.createElement(Text, null, "AsciiMotion"),
StaticRobotFrame: () => React.createElement(Text, null, "StaticRobot"),
}))
vi.mock("./ChatMessage", () => ({
ChatMessage: ({ message }: { message?: { ts?: number } }) => React.createElement(Text, null, `Message: ${message?.ts}`),
}))
vi.mock("./FileMentionMenu", () => ({
FileMentionMenu: () => React.createElement(Text, null, "FileMentionMenu"),
}))
vi.mock("./HighlightedInput", () => ({
HighlightedInput: ({ text }: { text?: string }) => React.createElement(Text, null, `Input: ${text ?? ""}`),
}))
vi.mock("./HistoryPanelContent", () => ({
HistoryPanelContent: () => React.createElement(Text, null, "HistoryPanel"),
}))
vi.mock("./RulesPanelContent", () => ({
RulesPanelContent: () => React.createElement(Text, null, "RulesPanel"),
}))
vi.mock("./SettingsPanelContent", () => ({
SettingsPanelContent: () => React.createElement(Text, null, "SettingsPanel"),
}))
vi.mock("./SkillsPanelContent", () => ({
SkillsPanelContent: () => React.createElement(Text, null, "SkillsPanel"),
}))
vi.mock("./SlashCommandMenu", () => ({
SlashCommandMenu: () => React.createElement(Text, null, "SlashMenu"),
}))
vi.mock("./ThinkingIndicator", () => ({
ThinkingIndicator: () => React.createElement(Text, null, "ThinkingIndicator"),
}))
vi.mock("../utils/file-search", () => ({
checkAndWarnRipgrepMissing: vi.fn(() => false),
extractMentionQuery: vi.fn(() => ({ inMentionMode: false, query: "", atIndex: -1 })),
getRipgrepInstallInstructions: vi.fn(() => "brew install ripgrep"),
insertMention: vi.fn((text: string) => text),
searchWorkspaceFiles: vi.fn(async () => []),
}))
vi.mock("../utils/input", () => ({
isMouseEscapeSequence: vi.fn(() => false),
}))
vi.mock("../utils/parser", () => ({
jsonParseSafe: vi.fn((_text: string, defaultValue: unknown) => defaultValue),
parseImagesFromInput: vi.fn((text: string) => ({ prompt: text, imagePaths: [] })),
}))
vi.mock("../utils/tools", () => ({
isFileEditTool: vi.fn(() => false),
parseToolFromMessage: vi.fn(() => null),
}))
vi.mock("../utils/display", () => ({
setTerminalTitle: vi.fn(),
}))
vi.mock("../utils/cursor", () => ({
moveCursorUp: vi.fn((_text: string, pos: number) => pos),
moveCursorDown: vi.fn((_text: string, pos: number) => pos),
}))
vi.mock("@shared/combineCommandSequences", () => ({
combineCommandSequences: vi.fn((messages: unknown[]) => messages),
}))
vi.mock("@shared/getApiMetrics", () => ({
getApiMetrics: vi.fn(() => ({
totalTokensIn: 0,
totalTokensOut: 0,
totalCost: 0,
})),
getLastApiReqTotalTokens: vi.fn(() => 0),
}))
import { ChatView } from "./ChatView"
const delay = (ms = 60) => new Promise((resolve) => setTimeout(resolve, ms))
const ControllerHarness = ({ dropAfterMs }: { dropAfterMs: number }) => {
const [controller, setController] = React.useState<any>({})
React.useEffect(() => {
const timeout = setTimeout(() => setController(undefined), dropAfterMs)
return () => clearTimeout(timeout)
}, [dropAfterMs])
return <ChatView controller={controller} />
}
describe("Rules command controller guards", () => {
beforeEach(() => {
vi.clearAllMocks()
})
it("does not open the rules panel after the controller disappears", async () => {
const { stdin, lastFrame } = render(<ControllerHarness dropAfterMs={120} />)
await delay(240)
stdin.write("/rules")
await delay()
stdin.write("\r")
await delay()
const frame = lastFrame() || ""
expect(frame).not.toContain("RulesPanel")
expect(frame).toContain("Input:")
})
})
@@ -0,0 +1,48 @@
import { render } from "ink-testing-library"
// biome-ignore lint/correctness/noUnusedImports: React is needed for JSX
import React from "react"
import { beforeEach, describe, expect, it, vi } from "vitest"
const mockRefreshRules = vi.fn()
vi.mock("@/core/controller/file/refreshRules", () => ({
refreshRules: (...args: unknown[]) => mockRefreshRules(...args),
}))
vi.mock("@/core/controller/file/toggleClineRule", () => ({
toggleClineRule: vi.fn(),
}))
vi.mock("@shared/proto/cline/file", () => ({
RuleScope: { GLOBAL: 0, LOCAL: 1 },
}))
vi.mock("../context/StdinContext", () => ({
useStdinContext: () => ({ isRawModeSupported: true }),
}))
import { RulesPanelContent } from "./RulesPanelContent"
const delay = (ms = 60) => new Promise((resolve) => setTimeout(resolve, ms))
describe("RulesPanelContent", () => {
beforeEach(() => {
vi.clearAllMocks()
})
it("renders the basename for Windows rule paths", async () => {
mockRefreshRules.mockResolvedValue({
globalClineRulesToggles: {
toggles: {
"C:\\workspace\\.clinerules\\rules\\architecture.md": true,
},
},
})
const { lastFrame } = render(<RulesPanelContent controller={{} as any} onClose={vi.fn()} />)
await delay()
const frame = lastFrame() || ""
expect(frame).toContain("architecture.md")
expect(frame).not.toContain("C:\\workspace")
})
})
+248
View File
@@ -0,0 +1,248 @@
/**
* Rules panel content for inline display in ChatView
* Shows all rule types (Cline, Cursor, Windsurf, Agents) with toggle functionality
*/
import { RuleScope } from "@shared/proto/cline/file"
import { Box, Text, useInput } from "ink"
import React, { useCallback, useEffect, useState } from "react"
import type { Controller } from "@/core/controller"
import { refreshRules } from "@/core/controller/file/refreshRules"
import { toggleAgentsRule } from "@/core/controller/file/toggleAgentsRule"
import { toggleClineRule } from "@/core/controller/file/toggleClineRule"
import { toggleCursorRule } from "@/core/controller/file/toggleCursorRule"
import { toggleWindsurfRule } from "@/core/controller/file/toggleWindsurfRule"
import { useStdinContext } from "../context/StdinContext"
import { isMouseEscapeSequence } from "../utils/input"
import { Panel } from "./Panel"
interface RuleEntry {
name: string
path: string
enabled: boolean
isGlobal: boolean
ruleType: "cline" | "cursor" | "windsurf" | "agents"
}
interface RulesPanelContentProps {
controller: Controller
onClose: () => void
}
const MAX_VISIBLE = 10
const PATH_SEPARATOR_REGEX = /[\\/]/
function buildRuleEntries(toggles: Record<string, boolean>, isGlobal: boolean, ruleType: RuleEntry["ruleType"]): RuleEntry[] {
return Object.entries(toggles).map(([path, enabled]) => ({
name: path.split(PATH_SEPARATOR_REGEX).at(-1) || path,
path,
enabled,
isGlobal,
ruleType,
}))
}
function normalizeRefreshedRules(data: Awaited<ReturnType<typeof refreshRules>>): RuleEntry[] {
return [
...buildRuleEntries(data.globalClineRulesToggles?.toggles ?? {}, true, "cline"),
...buildRuleEntries(data.localClineRulesToggles?.toggles ?? {}, false, "cline"),
...buildRuleEntries(data.localCursorRulesToggles?.toggles ?? {}, false, "cursor"),
...buildRuleEntries(data.localWindsurfRulesToggles?.toggles ?? {}, false, "windsurf"),
...buildRuleEntries(data.localAgentsRulesToggles?.toggles ?? {}, false, "agents"),
]
}
export const RulesPanelContent: React.FC<RulesPanelContentProps> = ({ controller, onClose }) => {
const { isRawModeSupported } = useStdinContext()
const [entries, setEntries] = useState<RuleEntry[]>([])
const [selectedIndex, setSelectedIndex] = useState(0)
const [isLoading, setIsLoading] = useState(true)
const [isToggling, setIsToggling] = useState(false)
const [loadError, setLoadError] = useState<string | null>(null)
const loadRules = useCallback(async () => {
try {
const data = await refreshRules(controller, {})
setEntries(normalizeRefreshedRules(data))
setLoadError(null)
} catch (error) {
setLoadError(error instanceof Error ? error.message : String(error))
}
}, [controller])
// Load rules on mount
useEffect(() => {
const load = async () => {
await loadRules()
setIsLoading(false)
}
load()
}, [loadRules])
// Subscribe to external state changes so that toggles made in VSCode are
// reflected here immediately without requiring any user action.
useEffect(() => {
return controller.subscribeToExternalStateChange(() => {
loadRules()
})
}, [controller, loadRules])
// Handle toggle — calls the appropriate backend function then re-fetches
const handleToggle = useCallback(async () => {
const entry = entries[selectedIndex]
if (!entry || isToggling) return
setIsToggling(true)
try {
const newEnabled = !entry.enabled
switch (entry.ruleType) {
case "cline": {
const scope = entry.isGlobal ? RuleScope.GLOBAL : RuleScope.LOCAL
await toggleClineRule(controller, {
metadata: undefined,
rulePath: entry.path,
enabled: newEnabled,
scope,
})
break
}
case "cursor":
await toggleCursorRule(controller, { metadata: undefined, rulePath: entry.path, enabled: newEnabled })
break
case "windsurf":
await toggleWindsurfRule(controller, { metadata: undefined, rulePath: entry.path, enabled: newEnabled })
break
case "agents":
await toggleAgentsRule(controller, { metadata: undefined, rulePath: entry.path, enabled: newEnabled })
break
}
await loadRules()
} finally {
setIsToggling(false)
}
}, [controller, entries, selectedIndex, isToggling, loadRules])
useInput(
(input, key) => {
if (isMouseEscapeSequence(input)) return
if (key.escape) {
onClose()
return
}
// Navigation
if (key.upArrow || input === "k") {
setSelectedIndex((i) => (i > 0 ? i - 1 : entries.length - 1))
return
}
if (key.downArrow || input === "j") {
setSelectedIndex((i) => (i < entries.length - 1 ? i + 1 : 0))
return
}
// Toggle
if (input === " " || key.return) {
handleToggle()
return
}
},
{ isActive: isRawModeSupported },
)
// Scrolling window
const halfVisible = Math.floor(MAX_VISIBLE / 2)
const startIndex = Math.max(0, Math.min(selectedIndex - halfVisible, entries.length - MAX_VISIBLE))
if (isLoading) {
return (
<Panel label="Rules">
<Text color="gray">Loading rules...</Text>
</Panel>
)
}
if (entries.length === 0) {
return (
<Panel label="Rules">
<Box flexDirection="column" gap={1}>
{loadError ? (
<Text color="red">Error loading rules: {loadError}</Text>
) : (
<React.Fragment>
<Text color="gray">No rules found.</Text>
<Text>
Create a <Text color="white">.clinerules</Text> file or directory in your project root to add
rules.
</Text>
</React.Fragment>
)}
</Box>
</Panel>
)
}
// Determine section headers
const getSectionLabel = (entry: RuleEntry): string => {
if (entry.ruleType === "cline" && entry.isGlobal) return "Global Cline Rules"
if (entry.ruleType === "cline" && !entry.isGlobal) return "Workspace Cline Rules"
if (entry.ruleType === "cursor") return "Cursor Rules"
if (entry.ruleType === "windsurf") return "Windsurf Rules"
if (entry.ruleType === "agents") return "Agents Rules"
return ""
}
return (
<Panel label="Rules">
<Box flexDirection="column">
{entries.slice(startIndex, startIndex + MAX_VISIBLE).map((entry, idx) => {
const actualIndex = startIndex + idx
const prevEntry = entries[actualIndex - 1]
const showHeader = actualIndex === 0 || (prevEntry && getSectionLabel(prevEntry) !== getSectionLabel(entry))
return (
<React.Fragment key={`${entry.ruleType}-${entry.path}`}>
{showHeader && (
<Box marginTop={actualIndex > 0 ? 1 : 0}>
<Text bold color="gray">
{getSectionLabel(entry)}:
</Text>
</Box>
)}
<RuleRow
entry={entry}
isSelected={actualIndex === selectedIndex}
isToggling={actualIndex === selectedIndex && isToggling}
/>
</React.Fragment>
)
})}
{/* Help text */}
<Box marginTop={1}>
<Text color="gray">/ Navigate Space/Enter Toggle Esc Close</Text>
</Box>
</Box>
</Panel>
)
}
const RuleRow: React.FC<{ entry: RuleEntry; isSelected: boolean; isToggling: boolean }> = ({ entry, isSelected, isToggling }) => {
return (
<Box>
<Text color={isSelected ? "cyan" : undefined}>
{isSelected ? " " : " "}
{isToggling ? (
<Text color="yellow"></Text>
) : (
<Text color={entry.enabled ? "green" : "red"}>{entry.enabled ? "●" : "○"}</Text>
)}
<Text> </Text>
<Text bold={isSelected} color="white">
{entry.name}
</Text>
</Text>
</Box>
)
}
@@ -25,6 +25,9 @@ export async function toggleAgentsRule(controller: Controller, request: ToggleAg
toggles[rulePath] = enabled
controller.stateManager.setWorkspaceState("localAgentsRulesToggles", toggles)
// Flush immediately so cross-process file watchers detect the change right away.
await controller.stateManager.flushPendingState()
// Get the current state to return in the response
const agentsToggles = controller.stateManager.getWorkspaceStateKey("localAgentsRulesToggles")
@@ -55,6 +55,10 @@ export async function toggleClineRule(controller: Controller, request: ToggleCli
telemetryService.captureClineRuleToggled(controller.task.ulid, ruleFileName, enabled, isGlobal)
}
// Flush immediately so cross-process file watchers detect the change right away,
// rather than waiting for the 500ms debounce timer.
await controller.stateManager.flushPendingState()
// Get the current state to return in the response
const globalToggles = controller.stateManager.getGlobalSettingsKey("globalClineRulesToggles")
const localToggles = controller.stateManager.getWorkspaceStateKey("localClineRulesToggles")
@@ -25,6 +25,9 @@ export async function toggleCursorRule(controller: Controller, request: ToggleCu
toggles[rulePath] = enabled
controller.stateManager.setWorkspaceState("localCursorRulesToggles", toggles)
// Flush immediately so cross-process file watchers detect the change right away.
await controller.stateManager.flushPendingState()
// Get the current state to return in the response
const cursorToggles = controller.stateManager.getWorkspaceStateKey("localCursorRulesToggles")
@@ -25,6 +25,9 @@ export async function toggleWindsurfRule(controller: Controller, request: Toggle
toggles[rulePath] = enabled
controller.stateManager.setWorkspaceState("localWindsurfRulesToggles", toggles)
// Flush immediately so cross-process file watchers detect the change right away.
await controller.stateManager.flushPendingState()
// Return the toggles directly
return ClineRulesToggles.create({ toggles: toggles })
}
+31 -1
View File
@@ -75,6 +75,9 @@ export class Controller {
ocaAuthService: OcaAuthService
readonly stateManager: StateManager
// Listeners notified when external state changes are detected (used by CLI Ink components)
private externalStateListeners: Set<() => void> = new Set()
// NEW: Add workspace manager (optional initially)
private workspaceManager?: WorkspaceRootManager
private backgroundCommandRunning = false
@@ -129,7 +132,21 @@ export class Controller {
Logger.error("[Controller] Storage persistence failed (will retry):", error)
},
onSyncExternalChange: async () => {
await this.postStateToWebview()
// Notify CLI Ink components first — these are lightweight sync callbacks
// that must fire even if postStateToWebview fails (e.g. in CLI where
// getStateToPostToWebview may throw due to missing browser/OAuth modules).
for (const listener of this.externalStateListeners) {
try {
listener()
} catch (error) {
Logger.error("[Controller] External state listener error:", error)
}
}
try {
await this.postStateToWebview()
} catch (error) {
Logger.error("[Controller] Failed to post state to webview on external change:", error)
}
},
})
this.authService = AuthService.getInstance(this)
@@ -159,6 +176,19 @@ export class Controller {
Logger.log("[Controller] ClineProvider instantiated")
}
/**
* Subscribe to external state changes (e.g. another process toggled a rule).
* Used by CLI Ink components to re-fetch and re-render when state is updated externally.
*
* @returns Unsubscribe function — call it in useEffect cleanup to avoid memory leaks.
*/
subscribeToExternalStateChange(listener: () => void): () => void {
this.externalStateListeners.add(listener)
return () => {
this.externalStateListeners.delete(listener)
}
}
/*
VSCode extensions use the disposable pattern to clean up resources when the sidebar/editor tab is closed by the user or system. This applies to event listening, commands, interacting with the UI, etc.
- https://vscode-docs.readthedocs.io/en/stable/extensions/patterns-and-principles/
+140 -1
View File
@@ -1,3 +1,5 @@
import { readFileSync } from "node:fs"
import path from "node:path"
import type { ApiConfiguration, ModelInfo } from "@shared/api"
import {
ApiHandlerSettingsKeys,
@@ -8,6 +10,7 @@ import {
isSettingsKey,
type LocalState,
type LocalStateKey,
LocalStateKeys,
type RemoteConfigFields,
type SecretKey,
SecretKeys,
@@ -111,6 +114,15 @@ export class StateManager {
private persistenceTimeout: NodeJS.Timeout | null = null
private readonly PERSISTENCE_DELAY_MS = 500
private taskHistoryWatcher: FSWatcher | null = null
private workspaceStateWatcher: FSWatcher | null = null
private globalStateWatcher: FSWatcher | null = null
// Global state keys that should be synced across processes (rule toggles)
private static readonly WATCHED_GLOBAL_KEYS = [
"globalClineRulesToggles",
"globalWorkflowToggles",
"remoteRulesToggles",
] as const
// Callback for persistence errors
onPersistenceError?: (event: PersistenceErrorEvent) => void
@@ -149,6 +161,11 @@ export class StateManager {
// Start watcher for taskHistory.json so external edits update cache (no persist loop)
await StateManager.instance.setupTaskHistoryWatcher()
// Start watchers for workspaceState.json and globalState.json to sync rule toggles
// across processes (CLI ↔ VSCode) without requiring a restart.
await StateManager.instance.setupWorkspaceStateWatcher()
await StateManager.instance.setupGlobalStateWatcher()
StateManager.instance.isInitialized = true
await AgentConfigLoader.getInstance().ready()
@@ -571,6 +588,120 @@ export class StateManager {
}
}
/**
* Initialize chokidar watcher for workspaceState.json.
* When another process (e.g. VSCode) writes rule toggles, this updates
* the in-memory cache and fires onSyncExternalChange so the UI reflects
* the external change immediately.
*
* Self-writes are safe: the cache is updated in-memory before the debounced
* disk flush, so the watcher comparison finds no difference → no spurious event.
*/
private async setupWorkspaceStateWatcher(): Promise<void> {
try {
const wsFile = path.join(this.storage.workspaceStoragePath, "workspaceState.json")
if (this.workspaceStateWatcher) {
await this.workspaceStateWatcher.close()
this.workspaceStateWatcher = null
}
this.workspaceStateWatcher = chokidar.watch(wsFile, {
persistent: true,
ignoreInitial: true,
atomic: true,
awaitWriteFinish: { stabilityThreshold: 300, pollInterval: 100 },
})
const syncWorkspaceStateFromDisk = async () => {
try {
if (!this.isInitialized) return
let onDisk: Record<string, any> = {}
try {
onDisk = JSON.parse(readFileSync(wsFile, "utf-8"))
} catch {
return // File doesn't exist or invalid JSON — skip
}
let changed = false
for (const key of LocalStateKeys) {
const diskValue = onDisk[key] ?? {}
if (JSON.stringify(diskValue) !== JSON.stringify(this.workspaceStateCache[key])) {
this.workspaceStateCache[key] = diskValue
changed = true
}
}
if (changed) {
await this.onSyncExternalChange?.()
}
} catch (err) {
Logger.error("[StateManager] Failed to reload workspace state on change:", err)
}
}
this.workspaceStateWatcher
.on("add", () => syncWorkspaceStateFromDisk())
.on("change", () => syncWorkspaceStateFromDisk())
.on("error", (error) => Logger.error("[StateManager] WorkspaceState watcher error:", error))
} catch (err) {
Logger.error("[StateManager] Failed to set up workspaceState watcher:", err)
}
}
/**
* Initialize chokidar watcher for globalState.json.
* Watches rule-toggle keys so that a CLI toggle of a global rule is
* reflected in the VSCode webview without a restart.
*/
private async setupGlobalStateWatcher(): Promise<void> {
try {
const globalFile = path.join(this.storage.dataDir, "globalState.json")
if (this.globalStateWatcher) {
await this.globalStateWatcher.close()
this.globalStateWatcher = null
}
this.globalStateWatcher = chokidar.watch(globalFile, {
persistent: true,
ignoreInitial: true,
atomic: true,
awaitWriteFinish: { stabilityThreshold: 300, pollInterval: 100 },
})
const syncGlobalStateFromDisk = async () => {
try {
if (!this.isInitialized) return
let onDisk: Record<string, any> = {}
try {
onDisk = JSON.parse(readFileSync(globalFile, "utf-8"))
} catch {
return
}
let changed = false
for (const key of StateManager.WATCHED_GLOBAL_KEYS) {
const diskValue = onDisk[key] ?? {}
if (JSON.stringify(diskValue) !== JSON.stringify((this.globalStateCache as any)[key])) {
;(this.globalStateCache as any)[key] = diskValue
changed = true
}
}
if (changed) {
await this.onSyncExternalChange?.()
}
} catch (err) {
Logger.error("[StateManager] Failed to reload global state on change:", err)
}
}
this.globalStateWatcher
.on("add", () => syncGlobalStateFromDisk())
.on("change", () => syncGlobalStateFromDisk())
.on("error", (error) => Logger.error("[StateManager] GlobalState watcher error:", error))
} catch (err) {
Logger.error("[StateManager] Failed to set up globalState watcher:", err)
}
}
/**
* Convenience method for getting API configuration
* Ensures cache is initialized if not already done
@@ -706,11 +837,19 @@ export class StateManager {
clearTimeout(this.persistenceTimeout)
this.persistenceTimeout = null
}
// Close file watcher if active
// Close file watchers if active
if (this.taskHistoryWatcher) {
this.taskHistoryWatcher.close()
this.taskHistoryWatcher = null
}
if (this.workspaceStateWatcher) {
this.workspaceStateWatcher.close()
this.workspaceStateWatcher = null
}
if (this.globalStateWatcher) {
this.globalStateWatcher.close()
this.globalStateWatcher = null
}
this.pendingGlobalState.clear()
this.pendingSecrets.clear()
+6
View File
@@ -91,6 +91,12 @@ export const CLI_ONLY_COMMANDS: SlashCommand[] = [
section: "default",
cliCompatible: true,
},
{
name: "rules",
description: "View and toggle rules",
section: "default",
cliCompatible: true,
},
{
name: "skills",
description: "View and manage installed skills",