fix(claude): separate skills from slash commands (#1293)

* fix(claude): separate skills from slash commands

* fix(claude): make native skills ready before use

* fix(claude): keep skill commands before attachments

* fix(claude): align skill catalog with launch args

* fix(claude): include launch plugin skills

* fix(claude): use native loaded skill list

* refactor(claude): remove obsolete metadata callback

* fix(claude): wait for skill catalog before draining messages

* fix(claude): cancel deferred startup messages

* fix(claude): bound skill catalog startup probe
This commit is contained in:
SSU-WEI HUANG
2026-08-02 08:49:14 +08:00
committed by GitHub
parent 4683b0cb47
commit 82da539d32
12 changed files with 312 additions and 42 deletions
+1 -1
View File
@@ -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
+2
View File
@@ -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,
@@ -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()
}
})
})
+2 -2
View File
@@ -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
};
}
+100 -21
View File
@@ -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<void> {
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<Session>((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<typeof loadCatalog> | null = null;
const getCatalog = (): ReturnType<typeof loadCatalog> => {
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<void> {
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<typeof session.onUserMessage>[0];
type UserMessageArgs = Parameters<UserMessageHandler>;
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<void> {
// 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<void> {
};
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<void> {
onModeChange: createModeChangeHandler(session),
onSessionReady: (sessionInstance) => {
currentSessionRef.current = sessionInstance;
resolveSessionReady(sessionInstance);
if (nativeSkills) {
sessionInstance.setNativeSkillNames(nativeSkills.map((skill) => skill.name));
}
syncSessionModes();
},
mcpServers: {
@@ -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'
])
})
})
+70 -17
View File
@@ -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<SDKMetadata> {
export async function extractSDKMetadata(options: {
cwd?: string
claudeArgs?: readonly string[]
} = {}): Promise<SDKMetadata> {
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<SDKMetadata> {
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<SDKMetadata> {
const metadata: SDKMetadata = {
tools: systemMessage.tools,
skills: systemMessage.skills,
slashCommands: systemMessage.slash_commands
}
@@ -62,21 +129,7 @@ export async function extractSDKMetadata(): Promise<SDKMetadata> {
}
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)
})
}
+20
View File
@@ -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)
})
})
+2
View File
@@ -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(','))
+2
View File
@@ -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
+12
View File
@@ -23,6 +23,7 @@ export class Session extends AgentSessionBase<EnhancedMode> {
readonly startedBy: 'runner' | 'terminal';
readonly startingMode: 'local' | 'remote';
localLaunchFailure: LocalLaunchFailure | null = null;
private nativeSkillNames = new Set<string>();
constructor(opts: {
api: ApiClient;
@@ -97,6 +98,17 @@ export class Session extends AgentSessionBase<EnhancedMode> {
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 };
};