feat(T-1.4): pi adapter — events, commands, autoname

This commit is contained in:
Johannes Merz 2026-05-15 11:31:36 +02:00
parent 6f106d2411
commit f89abd1125
3 changed files with 223 additions and 0 deletions

View File

@ -0,0 +1,73 @@
/**
* Auto-naming via `pi -p` (S-09a).
*
* After a configurable number of user messages, spawn a cheap `pi -p` call
* to generate a short session name from the conversation context.
* The result is stored as the tmux session's @description.
*
* Gated by [autoname] enabled in config.toml (T-1.7 wires the config;
* until then defaults are used).
*
* Owner: T-1.4
*/
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { setDescription } from "../tmux/manager.js";
const execFileAsync = promisify(execFile);
export interface AutonameConfig {
enabled: boolean;
triggerAfter: number; // number of user messages before naming
model: string; // e.g. "claude-haiku-4-5"
}
export const DEFAULT_AUTONAME_CONFIG: AutonameConfig = {
enabled: true,
triggerAfter: 3,
model: "claude-haiku-4-5",
};
/**
* Attempt to auto-name a session using `pi -p`.
* If pi is not on PATH or the call fails, silently no-ops.
*
* @param sessionId tmux session name to set @description on
* @param context recent conversation context (short excerpt)
* @param cfg autoname configuration
*/
export async function autoname(
sessionId: string,
context: string,
cfg: AutonameConfig = DEFAULT_AUTONAME_CONFIG,
): Promise<void> {
if (!cfg.enabled) return;
const prompt = `Give a 2-4 word title for this conversation. Reply with only the title, no punctuation.\n\n${context.slice(0, 800)}`;
try {
const { stdout } = await execFileAsync(
"pi",
[
"-p",
"--model",
cfg.model,
"--no-session",
"--no-tools",
"--no-extensions",
"--no-skills",
"--offline",
prompt,
],
{ timeout: 15_000 },
);
const name = stdout.trim().slice(0, 60); // cap at 60 chars
if (name) {
await setDescription(sessionId, name);
}
} catch {
// Autoname failures are non-fatal
}
}

View File

@ -0,0 +1,51 @@
/**
* pi.getCommands() wrapper.
*
* Fetches available slash commands from the pi ExtensionAPI and normalises
* them into the shape used by the /sessions/:id/commands REST endpoint (T-1.6).
*
* Owner: T-1.4
*/
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
export interface SlashCommand {
name: string;
description: string;
args?: string;
}
/**
* Get the list of registered slash commands from pi.
* Returns an empty array if the API doesn't support getCommands.
*/
export async function getCommands(pi: ExtensionAPI): Promise<SlashCommand[]> {
try {
// getCommands may not exist in all pi versions
if (
typeof (pi as unknown as { getCommands?: unknown }).getCommands !==
"function"
) {
return [];
}
const raw = await (
pi as unknown as { getCommands: () => Promise<unknown[]> }
).getCommands();
if (!Array.isArray(raw)) return [];
return raw
.filter(
(c): c is { name: string; description?: string; args?: string } =>
c !== null &&
typeof c === "object" &&
typeof (c as { name?: unknown }).name === "string",
)
.map((c) => ({
name: c.name,
description: c.description ?? "",
args: c.args,
}));
} catch {
return [];
}
}

View File

@ -0,0 +1,99 @@
/**
* pi ExtensionAPI event subscriptions.
*
* Bridges pi's lifecycle events into the sidecar's state model.
* Emits structured state updates that the WebSocket broadcaster (T-1.5)
* can forward as IC-1 `{ type: "state"; value: ... }` frames.
*
* Subscribes to:
* - agent_start / agent_end "thinking" / "idle"
* - tool_start / tool_end "tool" (with tool name)
* - awaiting_input "awaiting-input"
*
* Owner: T-1.4
*/
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
/** IC-1 state values */
export type AgentState = "thinking" | "tool" | "idle" | "awaiting-input";
export interface StateEvent {
value: AgentState;
tool?: string;
ts: number;
}
export type StateCallback = (event: StateEvent) => void;
/**
* Subscribe to pi agent lifecycle events.
* Returns an unsubscribe function.
*/
export function subscribeAgentEvents(
pi: ExtensionAPI,
onState: StateCallback,
): () => void {
const unsubs: Array<() => void> = [];
// agent_start → thinking
try {
const off = pi.on("agent_start", () => {
onState({ value: "thinking", ts: Date.now() });
});
if (off) unsubs.push(off);
} catch {
// event may not exist in this pi version
}
// agent_end → idle
try {
const off = pi.on("agent_end", () => {
onState({ value: "idle", ts: Date.now() });
});
if (off) unsubs.push(off);
} catch {
// event may not exist
}
// tool_start → tool
try {
const off = pi.on("tool_start", (data: unknown) => {
const toolName =
data &&
typeof data === "object" &&
"name" in data &&
typeof (data as { name: unknown }).name === "string"
? (data as { name: string }).name
: undefined;
onState({ value: "tool", tool: toolName, ts: Date.now() });
});
if (off) unsubs.push(off);
} catch {
// event may not exist
}
// tool_end → thinking (agent is still running after tool)
try {
const off = pi.on("tool_end", () => {
onState({ value: "thinking", ts: Date.now() });
});
if (off) unsubs.push(off);
} catch {
// event may not exist
}
// awaiting_input → awaiting-input
try {
const off = pi.on("awaiting_input", () => {
onState({ value: "awaiting-input", ts: Date.now() });
});
if (off) unsubs.push(off);
} catch {
// event may not exist
}
return () => {
for (const off of unsubs) off();
};
}