pi-ask emits events on pi.events at ask-flow boundaries. Other extensions listen with plain pi.events.on() — no imports from pi-ask needed.
Two events:
| Channel | Payload | When |
|---|---|---|
ask:started |
{ source, title?, questionCount } |
After validation, before the ask UI opens |
ask:completed |
AskResult (see src/types.ts) |
After the ask flow closes (submitted, elaborated, or cancelled) |
{
source: "tool" | "answer" | "replay";
title?: string;
questionCount: number;
}source: "tool"— LLM called theask_usertoolsource: "answer"— user ran/answersource: "replay"— user ran/answer:againor/ask:replay
The full AskResult object (already defined in src/types.ts). Consumers check result.cancelled and result.mode:
cancelled: true— user dismissed the flowmode: "submit"+cancelled: false— normal submissionmode: "elaborate"+cancelled: false— elaboration submission
// In any other extension — no import from pi-ask needed
pi.events.on("ask:started", (data) => {
console.log(`Ask flow opened: ${data.questionCount} question(s) from ${data.source}`);
});
pi.events.on("ask:completed", (result) => {
const r = result as { cancelled: boolean; mode: string; answers: Record<string, any> };
if (r.cancelled) {
console.log("Ask flow cancelled");
return;
}
console.log("Ask flow completed:", r.mode, r.answers);
});- Validation failures — no flow opened, nothing to observe
- Non-interactive calls (
ctx.hasUI === false) — no UI flow /answerextraction failures or cancellation — pre-flow step
No new files. No threading. No controller changes.
Add events to the Pick<ExtensionAPI, ...> and emit inline:
async function executeAskTool(
pi: Pick<ExtensionAPI, "appendEntry" | "events">,
toolCallId: string,
params: AskParams,
_signal: AbortSignal | undefined,
_onUpdate: unknown,
ctx: ExtensionContext
) {
const validation = validateParams(params);
if (!validation.ok) {
return invalidPayloadResponse(params, validation.issues);
}
appendAskPayload(pi, { params, source: "tool", sourceEntryId: toolCallId });
if (!ctx.hasUI) {
return nonInteractiveResponse(validation.state);
}
pi.events.emit("ask:started", {
source: "tool",
title: params.title,
questionCount: params.questions.length,
});
ctx.ui.setWorkingVisible(false);
try {
const result = await runAskFlow(ctx, params);
pi.events.emit("ask:completed", result);
return successfulResponse(result);
} finally {
ctx.ui.setWorkingVisible(true);
}
}Add events to runAskAndSendSubmittedResult and emit inline:
async function runAskAndSendSubmittedResult(
pi: Pick<ExtensionAPI, "sendUserMessage" | "events">,
ctx: ExtensionContext,
params: AskParams,
options: { allowFreeform: boolean; source: "answer" | "replay" }
): Promise<void> {
pi.events.emit("ask:started", {
source: options.source,
title: params.title,
questionCount: params.questions.length,
});
const result = await withHiddenWorkingRow(ctx, () =>
runAskFlow(ctx, params, { allowFreeform: options.allowFreeform })
);
pi.events.emit("ask:completed", result);
if (result.cancelled) {
ctx.ui.notify("Ask form cancelled.", "info");
return;
}
sendAskResult(pi, result, ctx);
}Callers pass source:
runAnswerCommand→{ allowFreeform: true, source: "answer" }runReplayCommand→{ allowFreeform: ..., source: "replay" }
src/ui/controller.ts— no threading, no emission, untouchedsrc/state/— no changessrc/types.ts— no new types- No new files
Add test cases to tests/ask-tool.test.ts:
- Widen the mock
pito includeevents: { emit(), on() } - Assert
ask:startedis emitted with correct payload before the flow - Assert
ask:completedis emitted with the AskResult after the flow - Assert no events on validation failure
- Assert no events in non-interactive mode
| File | Change |
|---|---|
src/ask-tool.ts |
Widen Pick<> to include "events", emit ask:started and ask:completed |
src/answer-commands.ts |
Widen Pick<> to include "events", add source to options, emit both events |
tests/ask-tool.test.ts |
Widen mock, add emission assertions |
docs/contract.md |
Add "Event bus" section |
docs/architecture.md |
Add event emission invariant |