diff --git a/desktop/src/api/plugins.ts b/desktop/src/api/plugins.ts
index 56bddc8b..4708c847 100644
--- a/desktop/src/api/plugins.ts
+++ b/desktop/src/api/plugins.ts
@@ -3,6 +3,7 @@ import type {
PluginDetail,
PluginListResponse,
PluginReloadSummary,
+ PluginSessionReloadSummary,
PluginScope,
} from '../types/plugin'
@@ -36,10 +37,17 @@ export const pluginsApi = {
uninstall: (payload: PluginActionPayload) =>
api.post<{ ok: true; message: string }>('/api/plugins/uninstall', payload),
- reload: (cwd?: string) => {
- const query = cwd ? `?cwd=${encodeURIComponent(cwd)}` : ''
- return api.post<{ ok: true; summary: PluginReloadSummary }>(
- `/api/plugins/reload${query}`,
+ reload: (cwd?: string, sessionId?: string) => {
+ const query = new URLSearchParams()
+ if (cwd) query.set('cwd', cwd)
+ if (sessionId) query.set('sessionId', sessionId)
+ const suffix = query.size > 0 ? `?${query.toString()}` : ''
+ return api.post<{
+ ok: true
+ summary: PluginReloadSummary
+ session?: PluginSessionReloadSummary
+ }>(
+ `/api/plugins/reload${suffix}`,
undefined,
{ timeout: 120_000 },
)
diff --git a/desktop/src/components/plugins/PluginDetail.tsx b/desktop/src/components/plugins/PluginDetail.tsx
index 14c0d897..abe9f5d6 100644
--- a/desktop/src/components/plugins/PluginDetail.tsx
+++ b/desktop/src/components/plugins/PluginDetail.tsx
@@ -82,7 +82,7 @@ export function PluginDetail() {
const handleReload = async () => {
setActionKey('reload')
try {
- const summary = await reloadPlugins(currentWorkDir)
+ const summary = await reloadPlugins(currentWorkDir, activeSessionId || undefined)
addToast({
type: summary.errors > 0 ? 'warning' : 'success',
message: t('settings.plugins.reloadToast', {
@@ -247,7 +247,7 @@ export function PluginDetail() {
variant="secondary"
size="sm"
loading={isApplying && actionKey === 'disable'}
- onClick={() => void runAction('disable', () => disablePlugin(selectedPlugin.id, selectedPlugin.scope, currentWorkDir))}
+ onClick={() => void runAction('disable', () => disablePlugin(selectedPlugin.id, selectedPlugin.scope, currentWorkDir, activeSessionId || undefined))}
>
{t('settings.plugins.disable')}
@@ -255,7 +255,7 @@ export function PluginDetail() {
@@ -267,7 +267,7 @@ export function PluginDetail() {
variant="secondary"
size="sm"
loading={isApplying && actionKey === 'update'}
- onClick={() => void runAction('update', () => updatePlugin(selectedPlugin.id, selectedPlugin.scope, currentWorkDir))}
+ onClick={() => void runAction('update', () => updatePlugin(selectedPlugin.id, selectedPlugin.scope, currentWorkDir, activeSessionId || undefined))}
>
{t('settings.plugins.update')}
@@ -489,7 +489,7 @@ export function PluginDetail() {
}}
onConfirm={async () => {
setShowUninstallDialog(false)
- await runAction('uninstall', () => uninstallPlugin(selectedPlugin.id, selectedPlugin.scope, false, currentWorkDir))
+ await runAction('uninstall', () => uninstallPlugin(selectedPlugin.id, selectedPlugin.scope, false, currentWorkDir, activeSessionId || undefined))
}}
title={t('settings.plugins.uninstall')}
body={t('settings.plugins.confirmUninstall', { name: selectedPlugin.name })}
diff --git a/desktop/src/components/plugins/PluginList.tsx b/desktop/src/components/plugins/PluginList.tsx
index 7d8df76a..e7b5d4a0 100644
--- a/desktop/src/components/plugins/PluginList.tsx
+++ b/desktop/src/components/plugins/PluginList.tsx
@@ -54,7 +54,7 @@ export function PluginList() {
const handleReload = async () => {
try {
- const reloadSummary = await reloadPlugins(currentWorkDir)
+ const reloadSummary = await reloadPlugins(currentWorkDir, activeSessionId || undefined)
addToast({
type: reloadSummary.errors > 0 ? 'warning' : 'success',
message: t('settings.plugins.reloadToast', {
diff --git a/desktop/src/pages/EmptySession.test.tsx b/desktop/src/pages/EmptySession.test.tsx
index 7c24562b..dfa90160 100644
--- a/desktop/src/pages/EmptySession.test.tsx
+++ b/desktop/src/pages/EmptySession.test.tsx
@@ -1,4 +1,4 @@
-import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import '@testing-library/jest-dom'
@@ -98,6 +98,7 @@ import { useSessionStore } from '../stores/sessionStore'
import { useSettingsStore } from '../stores/settingsStore'
import { useTabStore } from '../stores/tabStore'
import { useUIStore } from '../stores/uiStore'
+import { usePluginStore } from '../stores/pluginStore'
import type { RepositoryContextResult } from '../api/sessions'
function okRepositoryContext(overrides: Partial = {}): RepositoryContextResult {
@@ -146,6 +147,7 @@ describe('EmptySession', () => {
const initialTabState = useTabStore.getInitialState()
const initialRuntimeState = useSessionRuntimeStore.getInitialState()
const initialUiState = useUIStore.getInitialState()
+ const initialPluginState = usePluginStore.getInitialState()
beforeEach(() => {
vi.clearAllMocks()
@@ -157,6 +159,7 @@ describe('EmptySession', () => {
useTabStore.setState(initialTabState, true)
useSessionRuntimeStore.setState(initialRuntimeState, true)
useUIStore.setState(initialUiState, true)
+ usePluginStore.setState(initialPluginState, true)
mocks.createSession.mockResolvedValue({ sessionId: 'draft-session' })
mocks.getRepositoryContext.mockResolvedValue(okRepositoryContext())
@@ -187,6 +190,7 @@ describe('EmptySession', () => {
useTabStore.setState(initialTabState, true)
useSessionRuntimeStore.setState(initialRuntimeState, true)
useUIStore.setState(initialUiState, true)
+ usePluginStore.setState(initialPluginState, true)
})
it('uses compact composer controls on phone-sized H5 browsers', async () => {
@@ -203,6 +207,45 @@ describe('EmptySession', () => {
expect(screen.getByTestId('empty-session-composer-panel')).toHaveClass('rounded-2xl')
})
+ it('refreshes empty-session slash commands after plugin reloads', async () => {
+ mocks.listSkills
+ .mockResolvedValueOnce({ skills: [] })
+ .mockResolvedValueOnce({
+ skills: [
+ {
+ name: 'draw:render',
+ description: 'Render with the drawing plugin.',
+ userInvocable: true,
+ },
+ ],
+ })
+
+ render()
+
+ await waitFor(() => {
+ expect(mocks.listSkills).toHaveBeenCalledTimes(1)
+ })
+
+ act(() => {
+ usePluginStore.setState({
+ lastReloadSummary: {
+ enabled: 1,
+ disabled: 0,
+ skills: 1,
+ agents: 0,
+ hooks: 0,
+ mcpServers: 0,
+ lspServers: 0,
+ errors: 0,
+ },
+ })
+ })
+
+ await waitFor(() => {
+ expect(mocks.listSkills).toHaveBeenCalledTimes(2)
+ })
+ })
+
it('integrates repository launch controls into the desktop composer panel', async () => {
render()
diff --git a/desktop/src/pages/EmptySession.tsx b/desktop/src/pages/EmptySession.tsx
index b1618838..64e02fd1 100644
--- a/desktop/src/pages/EmptySession.tsx
+++ b/desktop/src/pages/EmptySession.tsx
@@ -4,6 +4,7 @@ import { skillsApi } from '../api/skills'
import { useTranslation } from '../i18n'
import { useSessionStore } from '../stores/sessionStore'
import { useChatStore } from '../stores/chatStore'
+import { usePluginStore } from '../stores/pluginStore'
import { useSessionRuntimeStore, DRAFT_RUNTIME_SELECTION_KEY } from '../stores/sessionRuntimeStore'
import { useSettingsStore } from '../stores/settingsStore'
import { useUIStore } from '../stores/uiStore'
@@ -103,6 +104,7 @@ export function EmptySession() {
const setActiveView = useUIStore((state) => state.setActiveView)
const addToast = useUIStore((state) => state.addToast)
const currentModel = useSettingsStore((state) => state.currentModel)
+ const lastPluginReloadSummary = usePluginStore((state) => state.lastReloadSummary)
const draftRuntimeSelection = useSessionRuntimeStore((state) => state.selections[DRAFT_RUNTIME_SELECTION_KEY])
const draftRuntimeSelectionKey = draftRuntimeSelection
? `${draftRuntimeSelection.providerId ?? 'official'}:${draftRuntimeSelection.modelId}`
@@ -198,7 +200,7 @@ export function EmptySession() {
return () => {
cancelled = true
}
- }, [workDir])
+ }, [workDir, lastPluginReloadSummary])
const allSlashCommands = useMemo(
() => mergeSlashCommands(slashCommands, FALLBACK_SLASH_COMMANDS),
diff --git a/desktop/src/stores/pluginStore.test.ts b/desktop/src/stores/pluginStore.test.ts
new file mode 100644
index 00000000..1a6be4b8
--- /dev/null
+++ b/desktop/src/stores/pluginStore.test.ts
@@ -0,0 +1,91 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { usePluginStore } from './pluginStore'
+import { pluginsApi } from '../api/plugins'
+
+vi.mock('../api/plugins', () => ({
+ pluginsApi: {
+ list: vi.fn(),
+ detail: vi.fn(),
+ enable: vi.fn(),
+ disable: vi.fn(),
+ update: vi.fn(),
+ uninstall: vi.fn(),
+ reload: vi.fn(),
+ },
+}))
+
+const mockedPluginsApi = vi.mocked(pluginsApi)
+
+describe('pluginStore', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockedPluginsApi.list.mockResolvedValue({
+ plugins: [],
+ marketplaces: [],
+ summary: { total: 0, enabled: 0, errorCount: 0, marketplaceCount: 0 },
+ })
+ mockedPluginsApi.reload.mockResolvedValue({
+ ok: true,
+ summary: {
+ enabled: 1,
+ disabled: 0,
+ skills: 1,
+ agents: 0,
+ hooks: 0,
+ mcpServers: 0,
+ lspServers: 0,
+ errors: 0,
+ },
+ session: {
+ applied: true,
+ commands: 1,
+ agents: 0,
+ plugins: 1,
+ mcpServers: 0,
+ errors: 0,
+ },
+ })
+ usePluginStore.setState({
+ plugins: [],
+ marketplaces: [],
+ summary: null,
+ selectedPlugin: null,
+ lastReloadSummary: null,
+ isLoading: false,
+ isDetailLoading: false,
+ isApplying: false,
+ error: null,
+ })
+ })
+
+ it('reloads the active CLI session after enabling a plugin', async () => {
+ mockedPluginsApi.enable.mockResolvedValue({
+ ok: true,
+ message: 'enabled',
+ })
+
+ const message = await usePluginStore
+ .getState()
+ .enablePlugin('draw@test', 'user', '/workspace/project', 'session-1')
+
+ expect(message).toBe('enabled')
+ expect(mockedPluginsApi.enable).toHaveBeenCalledWith({
+ id: 'draw@test',
+ scope: 'user',
+ })
+ expect(mockedPluginsApi.reload).toHaveBeenCalledWith(
+ '/workspace/project',
+ 'session-1',
+ )
+ expect(usePluginStore.getState().lastReloadSummary).toEqual({
+ enabled: 1,
+ disabled: 0,
+ skills: 1,
+ agents: 0,
+ hooks: 0,
+ mcpServers: 0,
+ lspServers: 0,
+ errors: 0,
+ })
+ })
+})
diff --git a/desktop/src/stores/pluginStore.ts b/desktop/src/stores/pluginStore.ts
index 5fb39637..9c70b9ba 100644
--- a/desktop/src/stores/pluginStore.ts
+++ b/desktop/src/stores/pluginStore.ts
@@ -20,11 +20,11 @@ type PluginStore = {
error: string | null
fetchPlugins: (cwd?: string) => Promise
fetchPluginDetail: (id: string, cwd?: string) => Promise
- reloadPlugins: (cwd?: string) => Promise
- enablePlugin: (id: string, scope?: PluginScope, cwd?: string) => Promise
- disablePlugin: (id: string, scope?: PluginScope, cwd?: string) => Promise
- updatePlugin: (id: string, scope?: PluginScope, cwd?: string) => Promise
- uninstallPlugin: (id: string, scope?: PluginScope, keepData?: boolean, cwd?: string) => Promise
+ reloadPlugins: (cwd?: string, sessionId?: string) => Promise
+ enablePlugin: (id: string, scope?: PluginScope, cwd?: string, sessionId?: string) => Promise
+ disablePlugin: (id: string, scope?: PluginScope, cwd?: string, sessionId?: string) => Promise
+ updatePlugin: (id: string, scope?: PluginScope, cwd?: string, sessionId?: string) => Promise
+ uninstallPlugin: (id: string, scope?: PluginScope, keepData?: boolean, cwd?: string, sessionId?: string) => Promise
clearSelection: () => void
}
@@ -70,10 +70,10 @@ export const usePluginStore = create((set, get) => ({
}
},
- reloadPlugins: async (cwd) => {
+ reloadPlugins: async (cwd, sessionId) => {
set({ isApplying: true, error: null })
try {
- const { summary } = await pluginsApi.reload(cwd)
+ const { summary } = await pluginsApi.reload(cwd, sessionId)
await get().fetchPlugins(cwd)
const selected = get().selectedPlugin
if (selected) {
@@ -88,39 +88,43 @@ export const usePluginStore = create((set, get) => ({
}
},
- enablePlugin: async (id, scope, cwd) => {
+ enablePlugin: async (id, scope, cwd, sessionId) => {
return runAction(
() => pluginsApi.enable({ id, scope }),
set,
get,
cwd,
+ sessionId,
)
},
- disablePlugin: async (id, scope, cwd) => {
+ disablePlugin: async (id, scope, cwd, sessionId) => {
return runAction(
() => pluginsApi.disable({ id, scope }),
set,
get,
cwd,
+ sessionId,
)
},
- updatePlugin: async (id, scope, cwd) => {
+ updatePlugin: async (id, scope, cwd, sessionId) => {
return runAction(
() => pluginsApi.update({ id, scope }),
set,
get,
cwd,
+ sessionId,
)
},
- uninstallPlugin: async (id, scope, keepData = false, cwd) => {
+ uninstallPlugin: async (id, scope, keepData = false, cwd, sessionId) => {
return runAction(
() => pluginsApi.uninstall({ id, scope, keepData }),
set,
get,
cwd,
+ sessionId,
true,
)
},
@@ -133,11 +137,13 @@ async function runAction(
set: (updater: Partial) => void,
get: () => PluginStore,
cwd?: string,
+ sessionId?: string,
clearSelection = false,
): Promise {
set({ isApplying: true, error: null })
try {
const { message } = await action()
+ const { summary } = await pluginsApi.reload(cwd, sessionId)
await get().fetchPlugins(cwd)
const selected = get().selectedPlugin
if (clearSelection) {
@@ -145,7 +151,7 @@ async function runAction(
} else if (selected) {
await get().fetchPluginDetail(selected.id, cwd)
}
- set({ isApplying: false })
+ set({ isApplying: false, lastReloadSummary: summary })
return message
} catch (err) {
set({
diff --git a/desktop/src/types/plugin.ts b/desktop/src/types/plugin.ts
index eb948b9a..266ae2a2 100644
--- a/desktop/src/types/plugin.ts
+++ b/desktop/src/types/plugin.ts
@@ -99,3 +99,14 @@ export type PluginReloadSummary = {
lspServers: number
errors: number
}
+
+export type PluginSessionReloadSummary = {
+ applied: boolean
+ reason?: 'not_running' | 'failed'
+ commands: number
+ agents: number
+ plugins: number
+ mcpServers: number
+ errors: number
+ error?: string
+}
diff --git a/src/server/__tests__/plugins.test.ts b/src/server/__tests__/plugins.test.ts
index 8fbcf452..31694ce3 100644
--- a/src/server/__tests__/plugins.test.ts
+++ b/src/server/__tests__/plugins.test.ts
@@ -7,9 +7,13 @@ import { clearInstalledPluginsCache } from '../../utils/plugins/installedPlugins
import { clearPluginCache, loadAllPluginsCacheOnly } from '../../utils/plugins/pluginLoader.js'
import { resetSettingsCache } from '../../utils/settings/settingsCache.js'
import { handlePluginsApi } from '../api/plugins.js'
+import { conversationService } from '../services/conversationService.js'
+import { __resetWebSocketHandlerStateForTests, getSlashCommands } from '../ws/handler.js'
let tmpDir: string
let originalConfigDir: string | undefined
+let originalHasSession: typeof conversationService.hasSession
+let originalRequestControl: typeof conversationService.requestControl
function makeRequest(
method: string,
@@ -38,9 +42,15 @@ describe('Plugins API', () => {
clearInstalledPluginsCache()
clearPluginCache('plugins-api-test-setup')
resetSettingsCache()
+ __resetWebSocketHandlerStateForTests()
+ originalHasSession = conversationService.hasSession.bind(conversationService)
+ originalRequestControl = conversationService.requestControl.bind(conversationService)
})
afterEach(async () => {
+ conversationService.hasSession = originalHasSession
+ conversationService.requestControl = originalRequestControl
+ __resetWebSocketHandlerStateForTests()
clearInstalledPluginsCache()
clearPluginCache('plugins-api-test-teardown')
resetSettingsCache()
@@ -166,4 +176,69 @@ describe('Plugins API', () => {
expect(typeof body.summary.skills).toBe('number')
expect(typeof body.summary.errors).toBe('number')
})
+
+ it('POST /api/plugins/reload hot-reloads an active CLI session and updates slash commands', async () => {
+ const controlRequests: Array<{ sessionId: string; request: Record }> = []
+ conversationService.hasSession = ((sessionId: string) => sessionId === 'session-plugins') as typeof conversationService.hasSession
+ conversationService.requestControl = (async (
+ sessionId: string,
+ request: Record,
+ ) => {
+ controlRequests.push({ sessionId, request })
+ return {
+ commands: [
+ {
+ name: 'draw:render',
+ description: 'Render a drawing.',
+ argumentHint: '',
+ },
+ ],
+ agents: [{ name: 'draw-agent' }],
+ plugins: [{ name: 'draw', path: '/tmp/draw', source: 'draw@test' }],
+ mcpServers: [{ name: 'plugin:draw:server', type: 'connected' }],
+ error_count: 0,
+ }
+ }) as typeof conversationService.requestControl
+
+ const { req, url, segments } = makeRequest(
+ 'POST',
+ '/api/plugins/reload?sessionId=session-plugins',
+ {},
+ )
+ const res = await handlePluginsApi(req, url, segments)
+
+ expect(res.status).toBe(200)
+ const body = await res.json() as {
+ session: {
+ applied: boolean
+ commands: number
+ agents: number
+ plugins: number
+ mcpServers: number
+ errors: number
+ }
+ }
+
+ expect(controlRequests).toEqual([
+ {
+ sessionId: 'session-plugins',
+ request: { subtype: 'reload_plugins' },
+ },
+ ])
+ expect(body.session).toEqual({
+ applied: true,
+ commands: 1,
+ agents: 1,
+ plugins: 1,
+ mcpServers: 1,
+ errors: 0,
+ })
+ expect(getSlashCommands('session-plugins')).toEqual([
+ {
+ name: 'draw:render',
+ description: 'Render a drawing.',
+ argumentHint: '',
+ },
+ ])
+ })
})
diff --git a/src/server/api/plugins.ts b/src/server/api/plugins.ts
index 7a6f9794..1ed6602f 100644
--- a/src/server/api/plugins.ts
+++ b/src/server/api/plugins.ts
@@ -1,9 +1,22 @@
import type { PluginScope } from '../../utils/plugins/schemas.js'
import { ApiError, errorResponse } from '../middleware/errorHandler.js'
import { PluginService } from '../services/pluginService.js'
+import { conversationService } from '../services/conversationService.js'
+import { updateSessionSlashCommands } from '../ws/handler.js'
const pluginService = new PluginService()
+type PluginSessionReloadSummary = {
+ applied: boolean
+ reason?: 'not_running' | 'failed'
+ commands: number
+ agents: number
+ plugins: number
+ mcpServers: number
+ errors: number
+ error?: string
+}
+
export async function handlePluginsApi(
req: Request,
url: URL,
@@ -29,7 +42,16 @@ export async function handlePluginsApi(
}
if (method === 'POST' && sub === 'reload') {
- return Response.json(await pluginService.reloadPlugins(cwd))
+ const sessionId = url.searchParams.get('sessionId') || undefined
+ const response = await pluginService.reloadPlugins(cwd)
+ if (!sessionId) {
+ return Response.json(response)
+ }
+
+ return Response.json({
+ ...response,
+ session: await reloadSessionPlugins(sessionId),
+ })
}
if (method === 'POST' && sub) {
@@ -73,6 +95,52 @@ export async function handlePluginsApi(
}
}
+async function reloadSessionPlugins(
+ sessionId: string,
+): Promise {
+ if (!conversationService.hasSession(sessionId)) {
+ return {
+ applied: false,
+ reason: 'not_running',
+ commands: 0,
+ agents: 0,
+ plugins: 0,
+ mcpServers: 0,
+ errors: 0,
+ }
+ }
+
+ try {
+ const response = await conversationService.requestControl(
+ sessionId,
+ { subtype: 'reload_plugins' },
+ 120_000,
+ )
+ const commands = Array.isArray(response.commands) ? response.commands : []
+ const normalizedCommands = updateSessionSlashCommands(sessionId, commands)
+
+ return {
+ applied: true,
+ commands: normalizedCommands.length,
+ agents: Array.isArray(response.agents) ? response.agents.length : 0,
+ plugins: Array.isArray(response.plugins) ? response.plugins.length : 0,
+ mcpServers: Array.isArray(response.mcpServers) ? response.mcpServers.length : 0,
+ errors: typeof response.error_count === 'number' ? response.error_count : 0,
+ }
+ } catch (error) {
+ return {
+ applied: false,
+ reason: 'failed',
+ commands: 0,
+ agents: 0,
+ plugins: 0,
+ mcpServers: 0,
+ errors: 0,
+ error: error instanceof Error ? error.message : String(error),
+ }
+ }
+}
+
async function parseJsonBody(req: Request): Promise> {
try {
return (await req.json()) as Record
diff --git a/src/server/ws/handler.ts b/src/server/ws/handler.ts
index 9eda066b..5ca50978 100644
--- a/src/server/ws/handler.ts
+++ b/src/server/ws/handler.ts
@@ -32,7 +32,13 @@ const providerService = new ProviderService()
/**
* Cache slash commands from CLI init messages, keyed by sessionId.
*/
-const sessionSlashCommands = new Map>()
+export type SessionSlashCommand = {
+ name: string
+ description: string
+ argumentHint?: string
+}
+
+const sessionSlashCommands = new Map()
/**
* Timers for delayed session cleanup after client disconnect.
@@ -86,7 +92,7 @@ async function sendRepositoryStartupStatus(
}
}
-export function getSlashCommands(sessionId: string): Array<{ name: string; description: string }> {
+export function getSlashCommands(sessionId: string): SessionSlashCommand[] {
return sessionSlashCommands.get(sessionId) || []
}
@@ -785,10 +791,7 @@ function cacheSessionInitMetadata(sessionId: string, cliMsg: any) {
})()
}
if (cliMsg.slash_commands && Array.isArray(cliMsg.slash_commands)) {
- sessionSlashCommands.set(sessionId, cliMsg.slash_commands.map((cmd: any) => ({
- name: typeof cmd === 'string' ? cmd : (cmd.name || cmd.command || ''),
- description: typeof cmd === 'string' ? '' : (cmd.description || ''),
- })))
+ updateSessionSlashCommands(sessionId, cliMsg.slash_commands, { notifyClient: false })
}
}
@@ -1568,6 +1571,55 @@ export function sendToSession(sessionId: string, message: ServerMessage): boolea
return true
}
+export function updateSessionSlashCommands(
+ sessionId: string,
+ commands: unknown[],
+ options: { notifyClient?: boolean } = {},
+): SessionSlashCommand[] {
+ const normalized = commands
+ .map(normalizeSessionSlashCommand)
+ .filter((command): command is SessionSlashCommand => command !== null)
+
+ sessionSlashCommands.set(sessionId, normalized)
+
+ if (options.notifyClient !== false) {
+ sendToSession(sessionId, {
+ type: 'system_notification',
+ subtype: 'slash_commands',
+ data: normalized,
+ })
+ }
+
+ return normalized
+}
+
+function normalizeSessionSlashCommand(command: unknown): SessionSlashCommand | null {
+ if (typeof command === 'string') {
+ return command.trim() ? { name: command, description: '' } : null
+ }
+ if (!command || typeof command !== 'object') return null
+
+ const record = command as {
+ name?: unknown
+ command?: unknown
+ description?: unknown
+ argumentHint?: unknown
+ }
+ const name =
+ typeof record.name === 'string'
+ ? record.name
+ : typeof record.command === 'string'
+ ? record.command
+ : ''
+ if (!name.trim()) return null
+
+ return {
+ name,
+ description: typeof record.description === 'string' ? record.description : '',
+ ...(typeof record.argumentHint === 'string' ? { argumentHint: record.argumentHint } : {}),
+ }
+}
+
export function closeSessionConnection(sessionId: string, reason = 'session closed'): boolean {
const cleanupTimer = sessionCleanupTimers.get(sessionId)
if (cleanupTimer) {