diff --git a/extensions/remote-control/pi/autoname.ts b/extensions/remote-control/pi/autoname.ts new file mode 100644 index 0000000..ca6ece2 --- /dev/null +++ b/extensions/remote-control/pi/autoname.ts @@ -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 { + 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 + } +} diff --git a/extensions/remote-control/pi/commands.ts b/extensions/remote-control/pi/commands.ts new file mode 100644 index 0000000..9427e36 --- /dev/null +++ b/extensions/remote-control/pi/commands.ts @@ -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 { + 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 } + ).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 []; + } +} diff --git a/extensions/remote-control/pi/events.ts b/extensions/remote-control/pi/events.ts new file mode 100644 index 0000000..86b68c6 --- /dev/null +++ b/extensions/remote-control/pi/events.ts @@ -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(); + }; +}