diff --git a/cli/src/pi/runPi.test.ts b/cli/src/pi/runPi.test.ts index b9088510..9734399b 100644 --- a/cli/src/pi/runPi.test.ts +++ b/cli/src/pi/runPi.test.ts @@ -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(() => { diff --git a/cli/src/pi/runPi.ts b/cli/src/pi/runPi.ts index f5e961e4..51b4ba16 100644 --- a/cli/src/pi/runPi.ts +++ b/cli/src/pi/runPi.ts @@ -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 => { + 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, 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 diff --git a/shared/src/slashCommands.ts b/shared/src/slashCommands.ts index a4f1aca7..a8aebf9f 100644 --- a/shared/src/slashCommands.ts +++ b/shared/src/slashCommands.ts @@ -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 export function getBuiltinSlashCommands(agent: string): SlashCommand[] { diff --git a/web/src/lib/codexSlashCommands.test.ts b/web/src/lib/codexSlashCommands.test.ts index 698afd18..a0375e9b 100644 --- a/web/src/lib/codexSlashCommands.test.ts +++ b/web/src/lib/codexSlashCommands.test.ts @@ -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')