pi.on("project_trust",async(event,ctx)=>{// event.cwd - current working directory
// ctx has a limited trust context: cwd, mode, hasUI, and select/confirm/input/notify UI helpers
if(awaitctx.ui.confirm("Trust project?",event.cwd)){return{trusted:"yes",remember: true};}return{trusted:"undecided"};});
pi.on("session_info_changed",async(event,ctx)=>{// event.name - current normalized name, or undefined if cleared
ctx.ui.notify(`Session renamed: ${event.name??"(none)"}`,"info");});
session_before_switch
在启动新会话(/new)或切换会话(/resume)之前触发。
pi.on("session_before_switch",async(event,ctx)=>{// event.reason - "new" or "resume"
// event.targetSessionFile - session we're switching to (only for "resume")
if(event.reason==="new"){constok=awaitctx.ui.confirm("Clear?","Delete all messages?");if(!ok)return{cancel: true};}});
pi.on("session_before_fork",async(event,ctx)=>{// event.entryId - ID of the selected entry
// event.position - "before" for /fork, "at" for /clone
return{cancel: true};// Cancel fork/clone
// OR
return{skipConversationRestore: true};// Reserved for future conversation restore control
});
pi.on("session_shutdown",async(event,ctx)=>{// event.reason - "quit" | "reload" | "new" | "resume" | "fork"
// event.targetSessionFile - destination session for session replacement flows
// Cleanup, save state, etc.
});
智能体事件
before_agent_start
在用户提交提示词之后、智能体循环开始之前触发。可以注入消息和/或修改系统提示词。
pi.on("before_agent_start",async(event,ctx)=>{// event.prompt - user's prompt text
// event.images - attached images (if any)
// event.systemPrompt - current chained system prompt for this handler
// (includes changes from earlier before_agent_start handlers)
// event.systemPromptOptions - structured options used to build the system prompt
// .customPrompt - any custom system prompt (from --system-prompt, SYSTEM.md, or custom templates)
// .selectedTools - tools currently active in the prompt
// .toolSnippets - one-line descriptions for each tool
// .promptGuidelines - custom guideline bullets
// .appendSystemPrompt - text from --append-system-prompt flags
// .cwd - working directory
// .contextFiles - AGENTS.md files and other loaded context files
// .skills - loaded skills
return{// Inject a persistent message (stored in session, sent to LLM)
message:{customType:"my-extension",content:"Additional context for the LLM",display: true,},// Replace the system prompt for this turn (chained across extensions)
systemPrompt: event.systemPrompt+"\n\nExtra instructions for this turn...",};});
systemPromptOptions 字段让扩展可以访问 pi 构建系统提示词所用的同一份结构化数据。这样你无需重新发现资源或重新解析标志,就能查看 pi 加载了什么——自定义提示词、指南、工具摘要、上下文文件、技能。当你的扩展需要在尊重用户配置的前提下对系统提示词做深入而知情的修改时,可以使用它。
agent_start 在低层智能体运行开始时触发。agent_end 在该运行结束时触发,但 pi 之后仍可能自动重试、自动上下文压缩后重试,或继续处理排队的后续消息。需要知道 pi 不会自动继续运行的集成(例如状态集成)请使用 agent_settled。
pi.on("agent_start",async(_event,ctx)=>{});pi.on("agent_end",async(event,ctx)=>{// event.messages - messages from this low-level run
});pi.on("agent_settled",async(_event,ctx)=>{// ctx.isIdle() is true here unless another extension started a new run.
});
pi.on("ui_prompt_start",async(event,ctx)=>{// event.reason === "ui_prompt"
// event.kind: "select" | "confirm" | "input" | "editor" | "custom"
// event.title: prompt title when available
});pi.on("ui_prompt_end",async(event,ctx)=>{// Pi is no longer waiting on that UI prompt span.
});
pi.on("context",async(event,ctx)=>{// event.messages - deep copy, safe to modify
constfiltered=event.messages.filter(m=>!shouldPrune(m));return{messages: filtered};});
pi.on("before_provider_headers",(event,ctx)=>{// Add or override — e.g. a session id for gateway tracing/attribution
event.headers["x-session-id"]=ctx.sessionManager.getSessionId();// Drop a tracking header pi adds for this call
event.headers["X-OpenRouter-Title"]=null;});
pi.on("input",async(event,ctx)=>{// event.text - raw input (before skill/template expansion)
// event.images - attached images, if any
// event.source - "interactive" (typed), "rpc" (API), or "extension" (via sendUserMessage)
// event.streamingBehavior - "steer" | "followUp" | undefined
// undefined when idle, "steer" for mid-stream interrupts,
// "followUp" for messages queued until the agent finishes
// Transform: rewrite input before expansion
if(event.text.startsWith("?quick "))return{action:"transform",text:`Respond briefly: ${event.text.slice(7)}`};// Handle: respond without LLM (extension shows its own feedback)
if(event.text==="ping"){ctx.ui.notify("pong","info");return{action:"handled"};}// Route by source: skip processing for extension-injected messages
if(event.source==="extension")return{action:"continue"};// Intercept skill commands before expansion
if(event.text.startsWith("/skill:")){// Could transform, block, or let pass through
}return{action:"continue"};// Default: pass through to expansion
});
ctx.sessionManager.getEntries()// All entries
ctx.sessionManager.getBranch()// Current branch
ctx.sessionManager.buildContextEntries()// Active branch entries with compaction applied
ctx.sessionManager.getLeafId()// Current leaf entry ID
pi.registerCommand("my-cmd",{handler: async(args,ctx)=>{awaitctx.waitForIdle();// Agent is now idle, safe to modify session
},});
ctx.newSession(options?)
创建新会话:
constparentSession=ctx.sessionManager.getSessionFile();constkickoff="Continue in the replacement session";constresult=awaitctx.newSession({parentSession,setup: async(sm)=>{sm.appendMessage({role:"user",content:[{type:"text",text:"Context from previous session..."}],timestamp: Date.now(),});},withSession: async(ctx)=>{// Use only the replacement-session ctx here.
awaitctx.sendUserMessage(kickoff);},});if(result.cancelled){// An extension cancelled the new session
}
选项:
parentSession:记录到新会话头中的父会话文件
setup:在 withSession 运行之前修改新会话的 SessionManager
withSession:在全新的替换会话上下文中执行切换后的工作。不要使用捕获的旧 pi / 命令 ctx;参见 会话替换的生命周期与易错点。
ctx.fork(entryId, options?)
从特定条目分叉,创建新的会话文件:
constresult=awaitctx.fork("entry-id-123",{withSession: async(ctx)=>{// Use only the replacement-session ctx here.
ctx.ui.notify("Now in the forked session","info");},});if(result.cancelled){// An extension cancelled the fork
}constcloneResult=awaitctx.fork("entry-id-456",{position:"at"});if(cloneResult.cancelled){// An extension cancelled the clone
}
选项:
position:"before"(默认)在所选用户消息之前分叉,并把该提示词恢复到编辑器中
position:"at" 复制穿过所选条目的活动路径,但不恢复编辑器文本
withSession:在全新的替换会话上下文中执行切换后的工作。不要使用捕获的旧 pi / 命令 ctx;参见 会话替换的生命周期与易错点。
constresult=awaitctx.switchSession("/path/to/session.jsonl",{withSession: async(ctx)=>{awaitctx.sendUserMessage("Resume work in the replacement session");},});if(result.cancelled){// An extension cancelled the switch via session_before_switch
}
选项:
withSession:在全新的替换会话上下文中执行切换后的工作。不要使用捕获的旧 pi / 命令 ctx;参见 会话替换的生命周期与易错点。
import{SessionManager}from"@earendil-works/pi-coding-agent";pi.registerCommand("switch",{description:"Switch to another session",handler: async(args,ctx)=>{constsessions=awaitSessionManager.list(ctx.cwd);if(sessions.length===0)return;constchoice=awaitctx.ui.select("Pick session:",sessions.map(s=>s.file),);if(choice){awaitctx.switchSession(choice,{withSession: async(ctx)=>{ctx.ui.notify("Switched session","info");},});}},});
pi.registerCommand("handoff",{handler: async(_args,ctx)=>{constkickoff="Continue from the replacement session";awaitctx.newSession({withSession: async(ctx)=>{awaitctx.sendUserMessage(kickoff);},});},});
不安全的写法:
pi.registerCommand("handoff",{handler: async(_args,ctx)=>{constoldSessionManager=ctx.sessionManager;awaitctx.newSession({withSession: async(_ctx)=>{// stale old objects: do not do this
oldSessionManager.getSessionFile();pi.sendUserMessage("wrong");},});},});
ctx.reload()
运行与 /reload 相同的重载流程。
pi.registerCommand("reload-runtime",{description:"Reload extensions, skills, prompts, themes, and context files",handler: async(_args,ctx)=>{awaitctx.reload();return;},});
import{Type}from"typebox";import{StringEnum}from"@earendil-works/pi-ai";pi.registerTool({name:"my_tool",label:"My Tool",description:"What this tool does",promptSnippet:"Summarize or transform text according to action",promptGuidelines:["Use my_tool when the user asks to summarize previously generated text."],parameters: Type.Object({action: StringEnum(["list","add"]asconst),text: Type.Optional(Type.String()),}),prepareArguments(args){// Optional compatibility shim. Runs before schema validation.
// Return the current schema shape, for example to fold legacy fields
// into the modern parameter object.
returnargs;},asyncexecute(toolCallId,params,signal,onUpdate,ctx){// Stream progress
onUpdate?.({content:[{type:"text",text:"Working..."}]});return{content:[{type:"text",text:"Done"}],details:{result:"..."},};},// Optional: Custom rendering
renderCall(args,theme,context){...},renderResult(result,options,theme,context){...},});
// Simple text message
pi.sendUserMessage("What is 2+2?");// With content array (text + images)
pi.sendUserMessage([{type:"text",text:"Describe this image:"},{type:"image",source:{type:"base64",mediaType:"image/png",data:"..."}},]);// During streaming - must specify delivery mode
pi.sendUserMessage("Focus on error handling",{deliverAs:"steer"});pi.sendUserMessage("And then summarize",{deliverAs:"followUp"});// Opt in to extension command dispatch and skill/prompt template expansion
pi.sendUserMessage("/review src/index.ts",{expandPromptTemplates: true});
// Set a label
pi.setLabel(entryId,"checkpoint-before-refactor");// Clear a label
pi.setLabel(entryId,undefined);// Read labels via sessionManager
constlabel=ctx.sessionManager.getLabel(entryId);
{name: string;// Invokable command name without the leading slash. May be suffixed like "review:1"
description?: string;source:"extension"|"prompt"|"skill";sourceInfo:{path: string;source: string;scope:"user"|"project"|"temporary";origin:"package"|"top-level";baseDir?: string;};}
constmodel=ctx.modelRegistry.find("anthropic","claude-sonnet-4-5");if(model){constsuccess=awaitpi.setModel(model);if(!success){ctx.ui.notify("No API key for this model","error");}}
pi.registerCommand("my-setup-teardown",{description:"Remove the custom proxy provider",handler: async(_args,_ctx)=>{pi.unregisterProvider("my-proxy");},});
状态管理
有状态的扩展应把状态存储在工具结果的 details 中,以便正确支持分支:
exportdefaultfunction(pi: ExtensionAPI){letitems: string[]=[];// Reconstruct state from session
pi.on("session_start",async(_event,ctx)=>{items=[];for(constentryofctx.sessionManager.getBranch()){if(entry.type==="message"&&entry.message.role==="toolResult"){if(entry.message.toolName==="my_tool"){items=entry.message.details?.items??[];}}}});pi.registerTool({name:"my_tool",// ...
asyncexecute(toolCallId,params,signal,onUpdate,ctx){items.push("new item");return{content:[{type:"text",text:"Added"}],details:{items:[...items]},// Store for reconstruction
};},});}
import{Type}from"typebox";import{StringEnum}from"@earendil-works/pi-ai";import{Text}from"@earendil-works/pi-tui";pi.registerTool({name:"my_tool",label:"My Tool",description:"What this tool does (shown to LLM)",promptSnippet:"List or add items in the project todo list",promptGuidelines:["Use my_tool for todo planning instead of direct file edits when the user asks for a task list."],parameters: Type.Object({action: StringEnum(["list","add"]asconst),// Use StringEnum for Google compatibility
text: Type.Optional(Type.String()),}),prepareArguments(args){if(!args||typeofargs!=="object")returnargs;constinput=argsas{action?: string;oldAction?: string};if(typeofinput.oldAction==="string"&&input.action===undefined){return{...input,action: input.oldAction};}returnargs;},asyncexecute(toolCallId,params,signal,onUpdate,ctx){// Check for cancellation
if(signal?.aborted){return{content:[{type:"text",text:"Cancelled"}]};}// Stream progress updates
onUpdate?.({content:[{type:"text",text:"Working..."}],details:{progress: 50},});// Run commands via pi.exec (captured from extension closure)
constresult=awaitpi.exec("some-command",[],{signal});// Return result
return{content:[{type:"text",text:"Done"}],// Sent to LLM
details:{data: result},// For rendering & state
// usage: nestedModelResponse.usage, // Optional nested LLM usage
// Optional: stop after this tool batch when every finalized tool result
// in the batch also returns terminate: true.
terminate: true,};},// Optional: Custom rendering
renderCall(args,theme,context){...},renderResult(result,options,theme,context){...},});
// Correct: throw to signal an error
asyncexecute(toolCallId,params){if(!isValid(params.input)){thrownewError(`Invalid input: ${params.input}`);}return{content:[{type:"text",text:"OK"}],details:{}};}
重要: 字符串枚举请使用 @earendil-works/pi-ai 的 StringEnum。Type.Union/Type.Literal 在 Google 的 API 上无法工作。
pi.registerTool({name:"edit",label:"Edit",description:"Edit a single file using exact text replacement",parameters: Type.Object({path: Type.String(),edits: Type.Array(Type.Object({oldText: Type.String(),newText: Type.String(),}),),}),prepareArguments(args){if(!args||typeofargs!=="object")returnargs;constinput=argsas{path?: string;edits?: Array<{oldText: string;newText: string}>;oldText?: unknown;newText?: unknown;};if(typeofinput.oldText!=="string"||typeofinput.newText!=="string"){returnargs;}return{...input,edits:[...(input.edits??[]),{oldText: input.oldText,newText: input.newText}],};},asyncexecute(toolCallId,params,signal,onUpdate,ctx){// params now matches the current schema
return{content:[{type:"text",text:`Applying ${params.edits.length} edit block(s)`}],details:{},};},});
import{createReadTool,createBashTool,typeReadOperations}from"@earendil-works/pi-coding-agent";// Create tool with custom operations
constremoteRead=createReadTool(cwd,{operations:{readFile:(path)=>sshExec(remote,`cat ${path}`),access:(path)=>sshExec(remote,`test -r ${path}`).then(()=>{}),}});// Register, checking flag at execution time
pi.registerTool({...remoteRead,asyncexecute(id,params,signal,onUpdate,_ctx){constssh=getSshConfig();if(ssh){consttool=createReadTool(cwd,{operations: createRemoteOps(ssh)});returntool.execute(id,params,signal,onUpdate);}returnlocalRead.execute(id,params,signal,onUpdate);},});
import{truncateHead,// Keep first N lines/bytes (good for file reads, search results)
truncateTail,// Keep last N lines/bytes (good for logs, command output)
truncateLine,// Truncate a single line to maxBytes with ellipsis
formatSize,// Human-readable size (e.g., "50KB", "1.5MB")
DEFAULT_MAX_BYTES,// 50KB
DEFAULT_MAX_LINES,// 2000
}from"@earendil-works/pi-coding-agent";asyncexecute(toolCallId,params,signal,onUpdate,ctx){constoutput=awaitrunCommand();// Apply truncation
consttruncation=truncateHead(output,{maxLines: DEFAULT_MAX_LINES,maxBytes: DEFAULT_MAX_BYTES,});letresult=truncation.content;if(truncation.truncated){// Write full output to temp file
consttempFile=writeTempFile(output);// Inform the LLM where to find complete output
result+=`\n\n[Output truncated: ${truncation.outputLines} of ${truncation.totalLines} lines`;result+=` (${formatSize(truncation.outputBytes)} of ${formatSize(truncation.totalBytes)}).`;result+=` Full output saved to: ${tempFile}]`;}return{content:[{type:"text",text: result}]};}
importtype{ExtensionAPI}from"@earendil-works/pi-coding-agent";import{Type}from"typebox";constSEARCHABLE_TOOL_NAMES=newSet(["lookup_weather","search_issues"]);exportdefaultfunction(pi: ExtensionAPI){pi.registerTool({name:"lookup_weather",label:"Lookup Weather",description:"Look up the current weather for a city",parameters: Type.Object({city: Type.String()}),asyncexecute(_toolCallId,params){return{content:[{type:"text",text:`Weather for ${params.city}: sunny`}],details:{},};},});pi.registerTool({name:"search_issues",label:"Search Issues",description:"Search project issues by keyword",parameters: Type.Object({query: Type.String()}),asyncexecute(_toolCallId,params){return{content:[{type:"text",text:`No open issues matching ${params.query}`}],details:{},};},});pi.registerTool({name:"search_tools",label:"Search Tools",description:"Search for and enable tools relevant to a task",promptSnippet:"Search for additional tools when the active tools cannot perform the task",promptGuidelines:["Use search_tools when a task requires a capability that is not currently available.",],parameters: Type.Object({query: Type.String({description:"Capability or task to search for"}),limit: Type.Optional(Type.Integer({minimum: 1,maximum: 10})),}),asyncexecute(_toolCallId,params){constterms=params.query.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);constmatches=pi.getAllTools().filter((tool)=>SEARCHABLE_TOOL_NAMES.has(tool.name)).map((tool)=>({tool,score: terms.reduce((score,term)=>score+(`${tool.name}${tool.description}`.toLowerCase().includes(term)?1 : 0),0,),})).filter((match)=>match.score>0).sort((a,b)=>b.score-a.score).slice(0,params.limit??3).map((match)=>match.tool.name);if(matches.length===0){return{content:[{type:"text",text:`No tools found for: ${params.query}`}],details:{matches:[]},};}constactive=pi.getActiveTools();constadded=matches.filter((name)=>!active.includes(name));pi.setActiveTools([...newSet([...active,...added])]);return{content:[{type:"text",text: added.length>0?`Loaded tools: ${added.join(", ")}`:`Matching tools already active: ${matches.join(", ")}`,}],details:{matches,added},};},});pi.on("session_start",()=>{// Keep searchable tools registered but initially inactive. Preserve built-ins
// and tools owned by other extensions, and keep the loader itself active.
constinitialTools=pi.getActiveTools().filter((name)=>!SEARCHABLE_TOOL_NAMES.has(name),);pi.setActiveTools([...newSet([...initialTools,"search_tools"])]);});}
// Select from options
constchoice=awaitctx.ui.select("Pick one:",["A","B","C"]);// Confirm dialog
constok=awaitctx.ui.confirm("Delete?","This cannot be undone");// Text input
constname=awaitctx.ui.input("Name:","placeholder");// Multi-line editor
consttext=awaitctx.ui.editor("Edit:","prefilled text");// Notification (non-blocking)
ctx.ui.notify("Done!","info");// "info" | "warning" | "error"
带倒计时的定时对话框
对话框支持 timeout 选项,会显示实时倒计时并在归零时自动关闭:
// Dialog shows "Title (5s)" → "Title (4s)" → ... → auto-dismisses at 0
constconfirmed=awaitctx.ui.confirm("Timed Confirmation","This dialog will auto-cancel in 5 seconds. Confirm?",{timeout: 5000});if(confirmed){// User confirmed
}else{// User cancelled or timed out
}
超时时的返回值:
select() 返回 undefined
confirm() 返回 false
input() 返回 undefined
使用 AbortSignal 手动关闭
需要更多控制时(例如区分超时与用户取消),可以使用 AbortSignal:
constcontroller=newAbortController();consttimeoutId=setTimeout(()=>controller.abort(),5000);constconfirmed=awaitctx.ui.confirm("Timed Confirmation","This dialog will auto-cancel in 5 seconds. Confirm?",{signal: controller.signal});clearTimeout(timeoutId);if(confirmed){// User confirmed
}elseif(controller.signal.aborted){// Dialog timed out
}else{// User cancelled (pressed Escape or selected "No")
}
pi.on("session_start",(_event,ctx)=>{ctx.ui.addAutocompleteProvider((current)=>({triggerCharacters:["#"],asyncgetSuggestions(lines,cursorLine,cursorCol,options){constline=lines[cursorLine]??"";constbeforeCursor=line.slice(0,cursorCol);constmatch=beforeCursor.match(/(?:^|[ \t])#([^\s#]*)$/);if(!match){returncurrent.getSuggestions(lines,cursorLine,cursorCol,options);}return{prefix:`#${match[1]??""}`,items:[{value:"#2983",label:"#2983",description:"Extension API for registering custom @ autocomplete providers"},{value:"#2753",label:"#2753",description:"Reload stale resource settings"},],};},applyCompletion(lines,cursorLine,cursorCol,item,prefix){returncurrent.applyCompletion(lines,cursorLine,cursorCol,item,prefix);},shouldTriggerFileCompletion(lines,cursorLine,cursorCol){returncurrent.shouldTriggerFileCompletion?.(lines,cursorLine,cursorCol)??true;},}));});
import{Text,Component}from"@earendil-works/pi-tui";constresult=awaitctx.ui.custom<boolean>((tui,theme,keybindings,done)=>{consttext=newText("Press Enter to confirm, Escape to cancel",1,1);text.onKey=(key)=>{if(key==="return")done(true);if(key==="escape")done(false);returntrue;};returntext;});if(result){// User pressed Enter
}
constresult=awaitctx.ui.custom<string|null>((tui,theme,keybindings,done)=>newMyOverlayComponent({onClose: done}),{overlay: true,overlayOptions:{anchor:"top-right",width:"50%",margin: 2},onHandle:(handle)=>{handle.focus();// focus this overlay and bring it to the visual front
// handle.unfocus({ target: editorComponent }); // release input to a specific component
// handle.setHidden(true/false); // toggle visibility
// handle.hide(); // permanently remove
}});
pi.sendMessage({customType:"my-extension",// Matches registerMessageRenderer
content:"Status update",display: true,// Show in TUI
details:{...},// Available in renderer
});
// Foreground colors
theme.fg("toolTitle",text)// Tool names
theme.fg("accent",text)// Highlights
theme.fg("success",text)// Success (green)
theme.fg("error",text)// Errors (red)
theme.fg("warning",text)// Warnings (yellow)
theme.fg("muted",text)// Secondary text
theme.fg("dim",text)// Tertiary text
// Text styles
theme.bold(text)theme.italic(text)theme.strikethrough(text)
在自定义工具渲染器中使用语法高亮:
import{highlightCode,getLanguageFromPath}from"@earendil-works/pi-coding-agent";// Highlight code with explicit language
consthighlighted=highlightCode("const x = 1;","typescript",theme);// Auto-detect language from file path
constlang=getLanguageFromPath("/path/to/file.rs");// "rust"
consthighlighted=highlightCode(code,lang,theme);