feat(T-1.4): pi adapter — events, commands, autoname
This commit is contained in:
parent
6f106d2411
commit
f89abd1125
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
};
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue