diff --git a/cli/src/claude/claudeLocal.ts b/cli/src/claude/claudeLocal.ts index 07b28c78..5bf1082d 100644 --- a/cli/src/claude/claudeLocal.ts +++ b/cli/src/claude/claudeLocal.ts @@ -105,7 +105,7 @@ export async function claudeLocal(opts: { // Prepare environment variables // Note: Local mode uses global Claude installation // - // SDK metadata extraction (extractSDKMetadataAsync → query()) sets + // SDK metadata extraction (extractSDKMetadata → query()) sets // CLAUDE_CODE_ENTRYPOINT='sdk-ts' on the current process. If leaked // into the local spawn, Claude Code thinks it was SDK-launched and // excludes the session from `claude --resume`. Destructure it out diff --git a/cli/src/claude/claudeRemote.ts b/cli/src/claude/claudeRemote.ts index 2e85ee15..07043811 100644 --- a/cli/src/claude/claudeRemote.ts +++ b/cli/src/claude/claudeRemote.ts @@ -11,6 +11,7 @@ import { systemPrompt } from "./utils/systemPrompt"; import { PermissionResult } from "./sdk/types"; import { getHapiBlobsDir } from "@/constants/uploadPaths"; import { getDefaultClaudeCodePath } from "./sdk/utils"; +import { filterCatalogAffectingClaudeArgs } from "./sdk/metadataExtractor"; export async function claudeRemote(opts: { @@ -129,6 +130,7 @@ export async function claudeRemote(opts: { // Prepare SDK options let mode = initial.mode; const sdkOptions: Options = { + additionalArgs: filterCatalogAffectingClaudeArgs(opts.claudeArgs), cwd: opts.path, resume: startFrom ?? undefined, mcpServers: opts.mcpServers, diff --git a/cli/src/claude/claudeRemoteLauncher.test.ts b/cli/src/claude/claudeRemoteLauncher.test.ts index 561fd8ec..59589710 100644 --- a/cli/src/claude/claudeRemoteLauncher.test.ts +++ b/cli/src/claude/claudeRemoteLauncher.test.ts @@ -11,6 +11,7 @@ import type { EnhancedMode } from './loop' const harness = vi.hoisted(() => ({ callCount: 0, claudeArgsPerCall: [] as (string[] | undefined)[], + initialMessages: [] as string[], triggerSwitch: null as (() => void) | null, switchAfterCall: 2 })) @@ -30,6 +31,7 @@ vi.mock('./claudeRemote', () => ({ // never fires and the --resume flag is never actually used. return } + harness.initialMessages.push(initial.message) // Mirrors claudeRemote()'s /clear contract: it reports the context as // discarded and returns before spawning Claude, so onSessionFound @@ -139,6 +141,7 @@ describe('claudeRemoteLauncher resume anchor', () => { afterEach(() => { harness.callCount = 0 harness.claudeArgsPerCall = [] + harness.initialMessages = [] harness.triggerSwitch = null harness.switchAfterCall = 2 vi.clearAllMocks() @@ -249,4 +252,52 @@ describe('claudeRemoteLauncher resume anchor', () => { session.stopKeepAlive() } }) + + it('sends an advertised $skill through Claude native slash invocation', async () => { + const client = createClientStub() + const { session, queue } = createSession(client, undefined) + + try { + session.setNativeSkillNames(['hapi']) + expect(session.expandSkillReference('$unknown inspect')).toBe('$unknown inspect') + expect(session.expandSkillReference('ask $hapi')).toBe('ask $hapi') + queue.push('$hapi inspect', { permissionMode: 'default' }, 'local-1') + harness.switchAfterCall = 1 + harness.triggerSwitch = () => { + client.rpcHandlers.get(RPC_METHODS.Switch)?.() + } + + await claudeRemoteLauncher(session as any) + + expect(harness.initialMessages).toEqual(['/hapi inspect']) + } finally { + session.stopKeepAlive() + } + }) + + it('keeps an advertised $skill first when attachments are present', async () => { + const client = createClientStub() + const { session, queue } = createSession(client, undefined) + + try { + session.setNativeSkillNames(['hapi']) + const prompt = session.expandSkillReference( + '$hapi inspect', + '@C:\\Users\\Jane Doe\\input.txt' + ) + queue.push(prompt, { permissionMode: 'default' }, 'local-1') + harness.switchAfterCall = 1 + harness.triggerSwitch = () => { + client.rpcHandlers.get(RPC_METHODS.Switch)?.() + } + + await claudeRemoteLauncher(session as any) + + expect(harness.initialMessages).toEqual([ + '/hapi inspect\n\n@C:\\Users\\Jane Doe\\input.txt' + ]) + } finally { + session.stopKeepAlive() + } + }) }) diff --git a/cli/src/claude/claudeRemoteLauncher.ts b/cli/src/claude/claudeRemoteLauncher.ts index 3ce95243..4da0dc04 100644 --- a/cli/src/claude/claudeRemoteLauncher.ts +++ b/cli/src/claude/claudeRemoteLauncher.ts @@ -418,7 +418,7 @@ class ClaudeRemoteLauncher extends RemoteLauncherBase { sdkToLogConverter.updateSelectedModel(p.mode.model ?? null); inFlightMessage = { items: p.items, mode: p.mode, isolate: p.isolate }; deliveredMessageThisAttempt = true; - return p; + return { ...p, message: session.expandSkillReference(p.message) }; } let msg = await session.queue.waitForMessagesAndGetAsString(controller.signal); @@ -449,7 +449,7 @@ class ClaudeRemoteLauncher extends RemoteLauncherBase { inFlightMessage = { items: msg.items, mode: msg.mode, isolate: msg.isolate }; deliveredMessageThisAttempt = true; return { - message: msg.message, + message: session.expandSkillReference(msg.message), mode: msg.mode }; } diff --git a/cli/src/claude/runClaude.ts b/cli/src/claude/runClaude.ts index f9476707..1b14c497 100644 --- a/cli/src/claude/runClaude.ts +++ b/cli/src/claude/runClaude.ts @@ -4,7 +4,7 @@ import { AgentState, SessionEffort, SessionModel } from '@/api/types'; import { EnhancedMode, PermissionMode } from './loop'; import { MessageQueue2 } from '@/utils/MessageQueue2'; import { hashObject } from '@/utils/deterministicJson'; -import { extractSDKMetadataAsync } from '@/claude/sdk/metadataExtractor'; +import { classifyClaudeSlashCatalog, extractSDKMetadata } from '@/claude/sdk/metadataExtractor'; import { parseSpecialCommand } from '@/parsers/specialCommands'; import { getEnvironmentInfo } from '@/ui/doctor'; import { startHappyServer, toClaudeAllowedHapiMcpTools } from '@/claude/utils/startHappyServer'; @@ -18,11 +18,12 @@ import { createModeChangeHandler, createRunnerLifecycle, setControlledByUser } f import { isPermissionModeAllowedForFlavor } from '@hapi/protocol'; import { RPC_METHODS } from '@hapi/protocol/rpcMethods'; import { PermissionModeSchema } from '@hapi/protocol/schemas'; -import { formatMessageWithAttachments } from '@/utils/attachmentFormatter'; +import { formatAttachmentsForClaude, formatMessageWithAttachments } from '@/utils/attachmentFormatter'; import { normalizeClaudeSessionModel } from './model'; import { normalizeClaudeSessionEffort } from './effort'; import { normalizeHookPermissionMode } from './utils/hookPermissionMode'; import { getInvokedCwd } from '@/utils/invokedCwd'; +import { listSkills, type SkillSummary } from '@/modules/common/skills'; export interface StartOptions { model?: string @@ -75,29 +76,75 @@ export async function runClaude(options: StartOptions = {}): Promise { const { api, session, sessionInfo } = bootstrap; logger.debug(`Session created: ${sessionInfo.id}`); - // Extract SDK metadata in background and update session when ready - extractSDKMetadataAsync(async (sdkMetadata) => { - logger.debug('[start] SDK metadata extracted, updating session:', sdkMetadata); - try { - // Update session metadata with tools and slash commands - session.updateMetadata((currentMetadata) => ({ - ...currentMetadata, - tools: sdkMetadata.tools, - slashCommands: sdkMetadata.slashCommands - })); - logger.debug('[start] Session metadata updated with SDK capabilities'); - } catch (error) { - logger.debug('[start] Failed to update session metadata:', error); + const currentSessionRef: { current: Session | null } = { current: null }; + let resolveSessionReady!: (session: Session) => void; + const sessionReady = new Promise((resolve) => { + resolveSessionReady = resolve; + }); + let nativeSkills: SkillSummary[] | null = null; + + const loadCatalog = async () => { + const [sdkMetadata, discoveredSkills] = await Promise.all([ + extractSDKMetadata({ cwd: workingDirectory, claudeArgs: options.claudeArgs }), + listSkills(workingDirectory, { flavor: 'claude' }) + ]); + return { + sdkMetadata, + catalog: classifyClaudeSlashCatalog( + sdkMetadata.slashCommands, + discoveredSkills, + sdkMetadata.skills + ) + }; + }; + let catalogPromise: ReturnType | null = null; + const getCatalog = (): ReturnType => { + if (!catalogPromise) { + catalogPromise = loadCatalog().then((result) => { + const { sdkMetadata, catalog } = result; + logger.debug('[start] SDK metadata extracted, updating session:', sdkMetadata); + if (sdkMetadata.slashCommands === undefined) { + catalogPromise = null; + if (sdkMetadata.tools !== undefined) { + session.updateMetadata((currentMetadata) => ({ + ...currentMetadata, + tools: sdkMetadata.tools + })); + } + return result; + } + nativeSkills = catalog.skills; + currentSessionRef.current?.setNativeSkillNames(catalog.skills.map((skill) => skill.name)); + session.updateMetadata((currentMetadata) => ({ + ...currentMetadata, + tools: sdkMetadata.tools, + slashCommands: catalog.commands + })); + logger.debug('[start] Session metadata updated with SDK capabilities'); + return result; + }).catch((error) => { + catalogPromise = null; + throw error; + }); } + return catalogPromise; + }; + session.rpcHandlerManager.registerHandler(RPC_METHODS.ListSkills, async () => { + const result = await getCatalog(); + return result.sdkMetadata.slashCommands === undefined + ? { success: false, error: 'Claude skill catalog unavailable' } + : { success: true, skills: result.catalog.skills }; + }); + + // Extract SDK metadata in background and update session when ready + void getCatalog().catch((error) => { + logger.debug('[start] Failed to update session metadata:', error); }); // Start HAPI MCP server const happyServer = await startHappyServer(session); logger.debug(`[START] HAPI MCP server started at ${happyServer.url}`); - // Variable to track current session instance (updated via onSessionReady callback) - const currentSessionRef: { current: Session | null } = { current: null }; - const formatFailureReason = (message: string): string => { const maxLength = 200; if (message.length <= maxLength) { @@ -221,7 +268,11 @@ export async function runClaude(options: StartOptions = {}): Promise { sessionInstance.setEffort(currentEffort); logger.debug(`[loop] Synced session config for keepalive: permissionMode=${currentPermissionMode}, model=${currentModel ?? 'auto'}, effort=${currentEffort ?? 'auto'}`); }; - session.onUserMessage((message, localId) => { + type UserMessageHandler = Parameters[0]; + type UserMessageArgs = Parameters; + const deferredMessages: UserMessageArgs[] = []; + let messagePipelineReady = false; + const handleUserMessage: UserMessageHandler = (message, localId) => { const sessionPermissionMode = currentSessionRef.current?.getPermissionMode(); if (sessionPermissionMode && isPermissionModeAllowedForFlavor(sessionPermissionMode, 'claude')) { currentPermissionMode = sessionPermissionMode as PermissionMode; @@ -292,8 +343,14 @@ export async function runClaude(options: StartOptions = {}): Promise { // Check for special commands before processing const specialCommand = parseSpecialCommand(message.content.text); - // Format message text with attachments for Claude - const formattedText = formatMessageWithAttachments(message.content.text, message.content.attachments); + // Native slash skills must stay at the start of the prompt. Regular + // messages keep the existing attachment-first format. + const attachmentText = formatAttachmentsForClaude(message.content.attachments); + const expandedText = currentSessionRef.current?.expandSkillReference(message.content.text, attachmentText) + ?? message.content.text; + const formattedText = expandedText !== message.content.text + ? expandedText + : formatMessageWithAttachments(message.content.text, message.content.attachments); if (specialCommand.type === 'compact') { logger.debug('[start] Detected /compact command'); @@ -381,9 +438,27 @@ export async function runClaude(options: StartOptions = {}): Promise { }; messageQueue.push(formattedText, enhancedMode, localId); logger.debugLargeJson('User message pushed to queue:', message) + }; + session.onUserMessage((...args) => { + if (!messagePipelineReady) { + deferredMessages.push(args); + return; + } + handleUserMessage(...args); + }); + void Promise.allSettled([sessionReady, getCatalog()]).then(() => { + messagePipelineReady = true; + for (const args of deferredMessages.splice(0)) { + handleUserMessage(...args); + } }); session.onCancelQueuedMessage((localId) => { + const deferredIndex = deferredMessages.findIndex(([, id]) => id === localId); + if (deferredIndex >= 0) { + deferredMessages.splice(deferredIndex, 1); + return true; + } const removed = messageQueue.cancelByLocalId(localId); logger.debug(`[claude] cancelByLocalId(${localId}): ${removed ? 'removed' : 'not found (best-effort)'}`); return removed; @@ -458,6 +533,10 @@ export async function runClaude(options: StartOptions = {}): Promise { onModeChange: createModeChangeHandler(session), onSessionReady: (sessionInstance) => { currentSessionRef.current = sessionInstance; + resolveSessionReady(sessionInstance); + if (nativeSkills) { + sessionInstance.setNativeSkillNames(nativeSkills.map((skill) => skill.name)); + } syncSessionModes(); }, mcpServers: { diff --git a/cli/src/claude/sdk/metadataExtractor.test.ts b/cli/src/claude/sdk/metadataExtractor.test.ts new file mode 100644 index 00000000..1c6a8477 --- /dev/null +++ b/cli/src/claude/sdk/metadataExtractor.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest' +import { + classifyClaudeSlashCatalog, + filterCatalogAffectingClaudeArgs +} from './metadataExtractor' + +describe('Claude skill catalog', () => { + const discoveredSkills = [ + { name: 'hapi', description: 'Manage HAPI' }, + { name: 'ponytail', description: 'Keep code simple' }, + { name: 'scanner-only', description: 'Not loaded by Claude' } + ] + + it('separates native skills from slash commands and keeps plugin namespaces', () => { + expect(classifyClaudeSlashCatalog( + ['help', 'hapi', 'url-plugin:url-skill', 'ponytail:ponytail', 'review'], + discoveredSkills, + ['hapi', 'url-plugin:url-skill', 'ponytail:ponytail'] + )).toEqual({ + commands: ['help', 'review'], + skills: [ + { name: 'hapi', description: 'Manage HAPI' }, + { name: 'url-plugin:url-skill', description: undefined }, + { name: 'ponytail:ponytail', description: 'Keep code simple' } + ] + }) + }) + + it('keeps only launch arguments that affect the command catalog', () => { + expect(filterCatalogAffectingClaudeArgs([ + '--resume', 'session-id', + '--plugin-dir', '/tmp/my plugin', + '--settings=/tmp/settings.json', + '--add-dir', '/tmp/one', '/tmp/two', + '--disable-slash-commands', + '--model', 'sonnet' + ])).toEqual([ + '--plugin-dir', '/tmp/my plugin', + '--settings=/tmp/settings.json', + '--add-dir', '/tmp/one', '/tmp/two', + '--disable-slash-commands' + ]) + }) + +}) diff --git a/cli/src/claude/sdk/metadataExtractor.ts b/cli/src/claude/sdk/metadataExtractor.ts index 5b4a2195..52a195d9 100644 --- a/cli/src/claude/sdk/metadataExtractor.ts +++ b/cli/src/claude/sdk/metadataExtractor.ts @@ -6,18 +6,82 @@ import { query } from './query' import type { SDKSystemMessage } from './types' import { logger } from '@/ui/logger' +import type { SkillSummary } from '@/modules/common/skills' export interface SDKMetadata { tools?: string[] + skills?: string[] slashCommands?: string[] } +const CATALOG_FLAGS = new Set([ + '--bare', + '--disable-slash-commands', + '--safe-mode' +]) +const CATALOG_VALUE_FLAGS = new Set([ + '--add-dir', + '--plugin-dir', + '--plugin-url', + '--settings', + '--setting-sources' +]) + +export function filterCatalogAffectingClaudeArgs(args: readonly string[] | undefined): string[] { + if (!args) return [] + const filtered: string[] = [] + for (let i = 0; i < args.length; i++) { + const arg = args[i] + const equalsIndex = arg.indexOf('=') + if (CATALOG_FLAGS.has(arg) || (equalsIndex > 0 && CATALOG_VALUE_FLAGS.has(arg.slice(0, equalsIndex)))) { + filtered.push(arg) + continue + } + if (!CATALOG_VALUE_FLAGS.has(arg)) continue + filtered.push(arg) + while (i + 1 < args.length && !args[i + 1].startsWith('-')) { + filtered.push(args[++i]) + if (arg !== '--add-dir') break + } + } + return filtered +} + +export function classifyClaudeSlashCatalog( + names: readonly string[] | undefined, + discoveredSkills: readonly SkillSummary[], + loadedSkillNames?: readonly string[] +): { commands: string[]; skills: SkillSummary[] } { + const skillsByName = new Map(discoveredSkills.map((skill) => [skill.name, skill])) + const loadedSkills = loadedSkillNames ? new Set(loadedSkillNames) : null + const commands: string[] = [] + const skills: SkillSummary[] = [] + + for (const rawName of names ?? []) { + const name = rawName.trim() + if (!name) continue + const localName = name.slice(name.lastIndexOf(':') + 1) + const discoveredSkill = skillsByName.get(name) ?? skillsByName.get(localName) + if (loadedSkills?.has(name) || (!loadedSkills && discoveredSkill)) { + skills.push({ name, description: discoveredSkill?.description }) + } else { + commands.push(name) + } + } + + return { commands, skills } +} + /** * Extract SDK metadata by running a minimal query and capturing the init message * @returns SDK metadata containing tools and slash commands */ -export async function extractSDKMetadata(): Promise { +export async function extractSDKMetadata(options: { + cwd?: string + claudeArgs?: readonly string[] +} = {}): Promise { const abortController = new AbortController() + const timeout = setTimeout(() => abortController.abort(), 10_000) try { logger.debug('[metadataExtractor] Starting SDK metadata extraction') @@ -26,6 +90,8 @@ export async function extractSDKMetadata(): Promise { const sdkQuery = query({ prompt: 'hello', options: { + cwd: options.cwd, + additionalArgs: filterCatalogAffectingClaudeArgs(options.claudeArgs), allowedTools: ['Bash(echo)'], maxTurns: 1, abort: abortController.signal @@ -39,6 +105,7 @@ export async function extractSDKMetadata(): Promise { const metadata: SDKMetadata = { tools: systemMessage.tools, + skills: systemMessage.skills, slashCommands: systemMessage.slash_commands } @@ -62,21 +129,7 @@ export async function extractSDKMetadata(): Promise { } logger.debug('[metadataExtractor] Error extracting SDK metadata:', error) return {} + } finally { + clearTimeout(timeout) } } - -/** - * Extract SDK metadata asynchronously without blocking - * Fires the extraction and updates metadata when complete - */ -export function extractSDKMetadataAsync(onComplete: (metadata: SDKMetadata) => void): void { - extractSDKMetadata() - .then(metadata => { - if (metadata.tools || metadata.slashCommands) { - onComplete(metadata) - } - }) - .catch(error => { - logger.debug('[metadataExtractor] Async extraction failed:', error) - }) -} \ No newline at end of file diff --git a/cli/src/claude/sdk/query.test.ts b/cli/src/claude/sdk/query.test.ts index 72860249..df651794 100644 --- a/cli/src/claude/sdk/query.test.ts +++ b/cli/src/claude/sdk/query.test.ts @@ -97,4 +97,24 @@ describe('Query', () => { await expect(result.next()).rejects.toThrow('prompt failed') }) + + it('places additional launch arguments before HAPI settings', async () => { + const child = createFakeChild() + spawnMock.mockReturnValueOnce(child) + process.env.HAPI_CLAUDE_PATH = 'claude' + + const { query } = await import('./query') + query({ + prompt: 'hello', + options: { + additionalArgs: ['--plugin-dir', '/tmp/plugin'], + settingsPath: '/tmp/hapi-settings.json' + } + }) + + const args = spawnMock.mock.calls[0][1] as string[] + expect(args.indexOf('--plugin-dir')).toBeLessThan(args.indexOf('--settings')) + child.stdout.end() + child.emit('close', 0) + }) }) diff --git a/cli/src/claude/sdk/query.ts b/cli/src/claude/sdk/query.ts index 246fb32a..a8fc1d08 100644 --- a/cli/src/claude/sdk/query.ts +++ b/cli/src/claude/sdk/query.ts @@ -297,6 +297,7 @@ export function query(config: { const { prompt, options: { + additionalArgs = [], additionalDirectories = [], allowedTools = [], appendSystemPrompt, @@ -341,6 +342,7 @@ export function query(config: { } if (continueConversation) args.push('--continue') if (resume) args.push('--resume', resume) + args.push(...additionalArgs) if (settingsPath) args.push('--settings', settingsPath) if (allowedTools.length > 0) args.push('--allowedTools', allowedTools.join(',')) if (disallowedTools.length > 0) args.push('--disallowedTools', disallowedTools.join(',')) diff --git a/cli/src/claude/sdk/types.ts b/cli/src/claude/sdk/types.ts index 737ce61f..f05616c9 100644 --- a/cli/src/claude/sdk/types.ts +++ b/cli/src/claude/sdk/types.ts @@ -59,6 +59,7 @@ export interface SDKSystemMessage extends SDKMessage { model?: string cwd?: string tools?: string[] + skills?: string[] slash_commands?: string[] /** * Present on `subtype: 'status'` messages that report a /compact outcome. @@ -181,6 +182,7 @@ export interface CanCallToolCallback { */ export interface QueryOptions { abort?: AbortSignal + additionalArgs?: string[] additionalDirectories?: string[] allowedTools?: string[] appendSystemPrompt?: string diff --git a/cli/src/claude/session.ts b/cli/src/claude/session.ts index 687ec0fb..1dfc2ffa 100644 --- a/cli/src/claude/session.ts +++ b/cli/src/claude/session.ts @@ -23,6 +23,7 @@ export class Session extends AgentSessionBase { readonly startedBy: 'runner' | 'terminal'; readonly startingMode: 'local' | 'remote'; localLaunchFailure: LocalLaunchFailure | null = null; + private nativeSkillNames = new Set(); constructor(opts: { api: ApiClient; @@ -97,6 +98,17 @@ export class Session extends AgentSessionBase { this.effort = effort; }; + setNativeSkillNames = (names: readonly string[]): void => { + this.nativeSkillNames = new Set(names); + }; + + expandSkillReference = (message: string, trailingContext = ''): string => { + const match = /^\s*\$([^\s]+)(?=\s|$)/.exec(message); + if (!match || !this.nativeSkillNames.has(match[1])) return message; + const expanded = `/${match[1]}${message.slice(match[0].length)}`; + return trailingContext ? `${expanded}\n\n${trailingContext}` : expanded; + }; + recordLocalLaunchFailure = (message: string, exitReason: LocalLaunchExitReason): void => { this.localLaunchFailure = { message, exitReason }; }; diff --git a/web/src/hooks/queries/useSkills.ts b/web/src/hooks/queries/useSkills.ts index a50ee079..77c77bd4 100644 --- a/web/src/hooks/queries/useSkills.ts +++ b/web/src/hooks/queries/useSkills.ts @@ -39,7 +39,11 @@ export function useSkills( if (!api || !sessionId) { throw new Error('Session unavailable') } - return await api.getSkills(sessionId) + const response = await api.getSkills(sessionId) + if (!response.success) { + throw new Error(response.error ?? 'Failed to load skills') + } + return response }, enabled: Boolean(api && sessionId), staleTime: 30_000,