fix(pi): expose native skills through $ completion (#1286)

This commit is contained in:
SSU-WEI HUANG
2026-08-02 08:49:46 +08:00
committed by GitHub
parent 2556729ba8
commit 545af9b4e0
4 changed files with 125 additions and 25 deletions
+38 -1
View File
@@ -69,7 +69,44 @@ vi.mock('./piTransport', () => ({
},
}));
import { runPi } from './runPi';
import { buildPiCommandInventory, formatPiUserMessage, rewritePiSkillPrompt, runPi } from './runPi';
describe('Pi command namespaces', () => {
const commands = [
{ name: 'session-name', description: 'Rename session', source: 'extension' as const },
{ name: 'fix-tests', description: 'Fix tests', source: 'prompt' as const },
{ name: 'skill:brave-search', description: 'Search the web', source: 'skill' as const },
];
it('exposes native skills through $ and keeps them out of slash completion', () => {
expect(buildPiCommandInventory(commands)).toEqual({
skills: [
{ name: 'brave-search', description: 'Search the web' },
],
slashCommands: [
{ name: 'session-name', description: 'Rename session', source: 'plugin' },
{ name: 'fix-tests', description: 'Fix tests', source: 'user' },
],
});
});
it('rewrites HAPI $ skills to Pi native skill commands', () => {
expect(rewritePiSkillPrompt('$brave-search latest news', commands))
.toBe('/skill:brave-search latest news');
expect(rewritePiSkillPrompt('$new-skill now', [])).toBe('/skill:new-skill now');
expect(rewritePiSkillPrompt('$PATH', commands)).toBe('$PATH');
});
it('keeps the native skill command first when the message has attachments', () => {
expect(formatPiUserMessage('$brave-search', [{
id: 'attachment-1',
filename: 'query.txt',
mimeType: 'text/plain',
size: 5,
path: '/tmp/query.txt',
}], commands)).toBe('/skill:brave-search\n\n@/tmp/query.txt');
});
});
describe('runPi startup', () => {
beforeEach(() => {
+81 -24
View File
@@ -3,22 +3,72 @@ import { bootstrapExistingSession, bootstrapSession } from '@/agent/sessionFacto
import { registerKillSessionHandler } from '@/claude/registerKillSessionHandler';
import { registerLocalHandoffHandler } from '@/agent/localHandoff';
import { createRunnerLifecycle, createModeChangeHandler, setControlledByUser } from '@/agent/runnerLifecycle';
import { formatMessageWithAttachments } from '@/utils/attachmentFormatter';
import { formatAttachmentsForClaude, formatMessageWithAttachments } from '@/utils/attachmentFormatter';
import { getInvokedCwd } from '@/utils/invokedCwd';
import { PiTransport } from './piTransport';
import { PiSession } from './session';
import { parsePiModels, parsePiCommands, sendPiRpcAndWait, wireTransportEvents } from './loop';
import { PiThinkingLevelSchema, SetSessionConfigPayloadSchema } from './schemas';
import type { PiThinkingLevel } from './types';
import type { SlashCommandsResponse } from '@hapi/protocol/apiTypes';
import type { ListPiModelsResponse } from '@hapi/protocol/apiTypes';
import type { ListPiModelsResponse, PiCommandSummary, SlashCommand, SlashCommandsResponse } from '@hapi/protocol/apiTypes';
import { RPC_METHODS } from '@hapi/protocol/rpcMethods';
import type { ListSkillsResponse, SkillSummary } from '@/modules/common/skills';
import type { AttachmentMetadata } from '@/api/types';
// Grace period before force-draining prompts buffered during Pi startup when no
// get_state response arrives. Comfortably above the 10s Pi RPC timeout so a slow
// but healthy startup still flips ready via get_state first (issue #1143).
const PI_READY_FALLBACK_MS = 30_000;
function getPiSkillName(commandName: string): string {
return commandName.startsWith('skill:') ? commandName.slice('skill:'.length) : commandName;
}
export function buildPiCommandInventory(commands: readonly PiCommandSummary[]): {
skills: SkillSummary[];
slashCommands: SlashCommand[];
} {
const skills: SkillSummary[] = [];
const slashCommands: SlashCommand[] = [];
for (const command of commands) {
if (command.source === 'skill') {
const name = getPiSkillName(command.name);
if (name) skills.push({ name, description: command.description });
continue;
}
slashCommands.push({
name: command.name,
description: command.description,
source: command.source === 'prompt' ? 'user' : 'plugin',
});
}
return { skills, slashCommands };
}
export function rewritePiSkillPrompt(message: string, commands: readonly PiCommandSummary[]): string {
const match = /^(\s*)\$([a-z0-9]+(?:-[a-z0-9]+)*)(?=\s|$)/.exec(message);
if (!match) return message;
const command = commands.find(candidate =>
candidate.source === 'skill' && getPiSkillName(candidate.name) === match[2]
);
return `${match[1]}/${command?.name ?? `skill:${match[2]}`}${message.slice(match[0].length)}`;
}
export function formatPiUserMessage(
message: string,
attachments: AttachmentMetadata[] | undefined,
commands: readonly PiCommandSummary[],
): string {
const skillPrompt = rewritePiSkillPrompt(message, commands);
if (skillPrompt === message) return formatMessageWithAttachments(message, attachments);
const attachmentText = formatAttachmentsForClaude(attachments);
return attachmentText ? `${skillPrompt}\n\n${attachmentText}` : skillPrompt;
}
export async function runPi(opts: {
startedBy?: 'runner' | 'terminal';
startingMode?: 'local' | 'remote';
@@ -285,38 +335,45 @@ export async function runPi(opts: {
}
);
// --- Slash commands (Pi skills/commands) ---
const getPiCommands = async (): Promise<PiCommandSummary[]> => {
if (piSession.cachedPiCommands.length > 0) return piSession.cachedPiCommands;
try {
const data = await sendPiRpcAndWait(piSession, transport, { type: 'get_commands' });
const commands = parsePiCommands(data);
if (commands.length > 0) piSession.cachedPiCommands = commands;
return commands;
} catch {
return [];
}
};
// --- Pi commands and skills ---
apiSession.rpcHandlerManager.registerHandler<{ agent?: string }, SlashCommandsResponse>(
RPC_METHODS.ListSlashCommands,
async () => {
let commands = piSession.cachedPiCommands;
if (commands.length === 0) {
try {
const data = await sendPiRpcAndWait(piSession, transport, { type: 'get_commands' });
commands = parsePiCommands(data);
if (commands.length > 0) {
piSession.cachedPiCommands = commands;
}
} catch {
// Fall through to return empty
}
}
const { slashCommands } = buildPiCommandInventory(await getPiCommands());
return {
success: true,
commands: commands.map((cmd) => ({
name: cmd.name,
description: cmd.description,
source: cmd.source === 'skill' ? 'plugin' as const
: cmd.source === 'prompt' ? 'user' as const
: 'plugin' as const,
})),
commands: slashCommands,
};
}
);
apiSession.rpcHandlerManager.registerHandler<Record<string, never>, ListSkillsResponse>(
RPC_METHODS.ListSkills,
async () => {
const { skills } = buildPiCommandInventory(await getPiCommands());
return { success: true, skills };
}
);
// --- User message handler ---
apiSession.onUserMessage((message, localId) => {
const formattedText = formatMessageWithAttachments(message.content.text, message.content.attachments);
const formattedText = formatPiUserMessage(
message.content.text,
message.content.attachments,
piSession.cachedPiCommands,
);
// Gate the send behind Pi startup readiness. A prompt POSTed immediately
// after spawn (supported handoff pattern — hapi-ping-peer, intake
// scripts) would otherwise reach Pi before its initial get_state finishes
+2
View File
@@ -52,6 +52,8 @@ export const BUILTIN_SLASH_COMMANDS = {
cursor: [
{ name: 'compress', description: 'Compress conversation context to free window space (pass-through to Cursor agent)', source: 'builtin' },
],
kimi: [],
pi: [],
} as const satisfies Record<string, readonly SlashCommand[]>
export function getBuiltinSlashCommands(agent: string): SlashCommand[] {
+4
View File
@@ -27,6 +27,10 @@ describe('getBuiltinSlashCommands', () => {
)
})
it.each(['pi', 'kimi'])('does not fall back to Claude commands for %s', (flavor) => {
expect(getBuiltinSlashCommands(flavor)).toEqual([])
})
it('includes debug only in Cursor permission modes', () => {
expect(getPermissionModesForFlavor('cursor')).toContain('debug')
expect(getPermissionModesForFlavor('claude')).not.toContain('debug')