Files
backnotprop__plannotator/packages/ai/base-session.ts
Michael Ramos 877b0c78ac feat(ai): Pi provider via RPC subprocess (#377)
* refactor(ai): extract BaseSession and buildEffectivePrompt

Pull shared session lifecycle (~80 lines duplicated across Claude and
Codex providers) into BaseSession: query guard, abort, ID resolution,
generation counter. Extract first-query prompt prepending into
buildEffectivePrompt() in context.ts.

Both providers now extend BaseSession. Behavior-preserving — all 54
existing tests pass unchanged.

For provenance purposes, this commit was AI assisted.

* feat(ai): add Pi provider via RPC subprocess

Spawns `pi --mode rpc` and communicates via JSONL over stdio. No Pi SDK
bundled — user must have the pi CLI installed. Provider auto-discovers
available models at startup via get_available_models.

Includes: PiProcess (JSONL client), PiSDKProvider, PiSDKSession extending
BaseSession, mapPiEvent for streaming, Pi icon in provider UI, and 12
new tests (9 event mapping + 3 buildEffectivePrompt).

For provenance purposes, this commit was AI assisted.

* fix(ai): harden Pi provider error paths

- Surface startup errors when Pi process dies (bad config, missing API keys)
  instead of hanging the chat indefinitely
- Map tool-call errors as non-terminal tool_result instead of AIErrorMessage,
  so Pi can continue reasoning after a failed tool call
- Fix subprocess leak in fetchModels() by moving proc.kill() to finally block

For provenance purposes, this commit was AI assisted.

* fix(ai): detect Pi prompt rejections and process crashes

- Use sendAndWait for prompt command so RPC-level rejections (expired
  credentials, invalid session) surface as errors instead of hanging
- Emit process_exited instead of synthetic agent_end when Pi subprocess
  dies, so crashes show as errors instead of silent empty successes

For provenance purposes, this commit was AI assisted.

* docs(ai): add Pi to AI features guide, code review docs, and installation

For provenance purposes, this commit was AI assisted.

* style(ai): apply biome formatting to new files

For provenance purposes, this commit was AI assisted.
2026-03-23 15:50:30 -07:00

95 lines
2.8 KiB
TypeScript

/**
* Shared session base class — extracts the common lifecycle, abort, and
* ID-resolution logic that every AIProvider session needs.
*
* Concrete providers extend this and implement query().
*/
import type { AIMessage, AISession } from "./types.ts";
export abstract class BaseSession implements AISession {
readonly parentSessionId: string | null;
onIdResolved?: (oldId: string, newId: string) => void;
protected _placeholderId: string;
protected _resolvedId: string | null = null;
protected _isActive = false;
protected _currentAbort: AbortController | null = null;
protected _queryGen = 0;
protected _firstQuerySent = false;
constructor(opts: { parentSessionId: string | null; initialId?: string }) {
this.parentSessionId = opts.parentSessionId;
this._placeholderId = opts.initialId ?? crypto.randomUUID();
}
get id(): string {
return this._resolvedId ?? this._placeholderId;
}
get isActive(): boolean {
return this._isActive;
}
// ---------------------------------------------------------------------------
// Query lifecycle helpers — call from concrete query() implementations
// ---------------------------------------------------------------------------
/** Error message returned when a query is already active. */
static readonly BUSY_ERROR: AIMessage = {
type: "error",
error:
"A query is already in progress. Abort the current query before sending a new one.",
code: "session_busy",
};
/**
* Call at the start of query(). Returns the generation number and abort
* signal, or null if the session is busy.
*/
protected startQuery(): { gen: number; signal: AbortSignal } | null {
if (this._isActive) return null;
const gen = ++this._queryGen;
this._isActive = true;
this._currentAbort = new AbortController();
return { gen, signal: this._currentAbort.signal };
}
/**
* Call in the finally block of query(). Only clears state if the
* generation matches (prevents a stale finally from clobbering a newer query).
*/
protected endQuery(gen: number): void {
if (this._queryGen === gen) {
this._isActive = false;
this._currentAbort = null;
}
}
/**
* Call when the provider resolves the real session ID from the backend.
* Fires the onIdResolved callback so the SessionManager can remap its key.
*/
protected resolveId(newId: string): void {
if (this._resolvedId) return; // Already resolved
const oldId = this._placeholderId;
this._resolvedId = newId;
this.onIdResolved?.(oldId, newId);
}
/**
* Abort the current in-flight query. Subclasses should call super.abort()
* after any provider-specific cleanup.
*/
abort(): void {
if (this._currentAbort) {
this._currentAbort.abort();
this._isActive = false;
this._currentAbort = null;
}
}
abstract query(prompt: string): AsyncIterable<AIMessage>;
}