Files
oh-my-pi-telegram/lib/pi.ts
evandrodevbr 057281a3bb feat: human error states, operator toasts and a queue/origin view in the omp UI
- classify transport and API failures into a short state with the next step
- count undelivered messages and show them next to the sent counter
- toast on owner paired, connection recovered and reply not delivered (throttled)
- second status line previews the next queued turns
- host spinner names the Telegram prompt while its turn runs
- sanitize every Telegram-controlled string that reaches the widget
2026-10-01 17:33:37 -03:00

300 lines
8.6 KiB
TypeScript

/**
* pi SDK adapter boundary
* Zones: pi agent sdk boundary, shared adapters
* Owns direct pi SDK imports and exposes narrow bridge-facing helpers/types for the extension composition layer
*/
import type { AssistantMessageEvent } from "@earendil-works/pi-ai";
import {
type AgentEndEvent,
type AgentSettledEvent,
type AgentStartEvent,
type BeforeAgentStartEvent,
type ExtensionAPI,
type ExtensionCommandContext,
type ExtensionContext,
type InputEvent,
type SessionBeforeCompactEvent,
type SessionCompactEvent,
type SessionShutdownEvent,
type SessionStartEvent,
type SlashCommandInfo,
SettingsManager,
getAgentDir,
} from "@earendil-works/pi-coding-agent";
export type {
AgentEndEvent,
AgentSettledEvent,
AgentStartEvent,
AssistantMessageEvent,
BeforeAgentStartEvent,
ExtensionAPI,
ExtensionCommandContext,
ExtensionContext,
InputEvent,
SessionBeforeCompactEvent,
SessionCompactEvent,
SessionShutdownEvent,
SessionStartEvent,
SlashCommandInfo,
};
/**
* Agent data directory owned by the host runtime. Pi returns its own root and
* omp's legacy Pi shim forwards omp's profile-aware directory, so no argv or
* executable sniffing is needed.
*/
export { getAgentDir as getHostAgentDir };
export interface ToolExecutionStartEvent {
type: "tool_execution_start";
toolCallId: string;
toolName: string;
args: unknown;
}
export interface ToolExecutionUpdateEvent {
type: "tool_execution_update";
toolCallId: string;
toolName: string;
args: unknown;
partialResult: unknown;
}
export interface ToolExecutionEndEvent {
type: "tool_execution_end";
toolCallId: string;
toolName: string;
result: unknown;
isError: boolean;
}
export interface PiSettingsManager {
reload: () => Promise<void>;
flush: () => Promise<void>;
getEnabledModels: () => string[] | undefined;
setEnabledModels: (patterns: string[] | undefined) => void;
}
/**
* omp's `Settings` is what its legacy shim returns from
* `SettingsManager.create()`. It has no `reload()` or `get/setEnabledModels()`:
* it reloads through `reloadFromDisk()` and keeps `enabledModels` as a
* path-scoped typed setting.
*/
interface OmpSettingsSurface {
reloadFromDisk: () => Promise<void>;
flush: () => Promise<void>;
getGlobalSettings: () => { enabledModels?: unknown };
}
function isOmpSettings(value: object): value is OmpSettingsSurface {
return (
"reloadFromDisk" in value &&
"flush" in value &&
"getGlobalSettings" in value
);
}
export type PiSlashCommandInfo = SlashCommandInfo;
export type PiRunMode = "tui" | "rpc" | "json" | "print";
function isPiRunMode(value: unknown): value is PiRunMode {
return (
value === "tui" || value === "rpc" || value === "json" || value === "print"
);
}
export function getExtensionContextMode(ctx: unknown): PiRunMode | undefined {
const mode =
typeof ctx === "object" && ctx !== null
? (ctx as { mode?: unknown }).mode
: undefined;
return isPiRunMode(mode) ? mode : undefined;
}
export function isExtensionContextPassiveRunMode(ctx: unknown): boolean {
const mode = getExtensionContextMode(ctx);
return mode === "print" || mode === "json";
}
export function canStartPollingInExtensionContext(ctx: unknown): boolean {
return !isExtensionContextPassiveRunMode(ctx);
}
export function formatPollingStartBlockedByRunMode(ctx: unknown): string {
const mode = getExtensionContextMode(ctx);
return mode
? `Telegram polling is unavailable in Pi ${mode} mode. Use /telegram-connect from a long-lived Pi session.`
: "Telegram polling is unavailable in this Pi run mode.";
}
export function getSessionCompactionReason(
event: unknown,
): "manual" | "threshold" | "overflow" | "unknown" {
const reason =
event && typeof event === "object" && "reason" in event
? (event as { reason?: unknown }).reason
: undefined;
return reason === "manual" || reason === "threshold" || reason === "overflow"
? reason
: "unknown";
}
export type PiSendUserMessageOptions = NonNullable<
Parameters<ExtensionAPI["sendUserMessage"]>[1]
>;
export interface PiExtensionApiRuntimePorts {
sendUserMessage: ExtensionAPI["sendUserMessage"];
exec: ExtensionAPI["exec"];
getCommands: ExtensionAPI["getCommands"];
getThinkingLevel: ExtensionAPI["getThinkingLevel"];
setThinkingLevel: ExtensionAPI["setThinkingLevel"];
setModel: ExtensionAPI["setModel"];
}
export function createExtensionApiRuntimePorts(
api: Pick<
ExtensionAPI,
| "sendUserMessage"
| "exec"
| "getCommands"
| "getThinkingLevel"
| "setThinkingLevel"
| "setModel"
>,
): PiExtensionApiRuntimePorts {
return {
sendUserMessage: (content, options) =>
api.sendUserMessage(content, options),
exec: (command, args, options) => api.exec(command, args, options),
getCommands: () => api.getCommands(),
getThinkingLevel: () => api.getThinkingLevel(),
setThinkingLevel: (level) => api.setThinkingLevel(level),
setModel: (model) => api.setModel(model),
};
}
export function createSettingsManager(cwd: string): PiSettingsManager {
const manager: PiSettingsManager = SettingsManager.create(cwd);
if (!isOmpSettings(manager)) return manager;
return {
reload: () => manager.reloadFromDisk(),
flush: () => manager.flush(),
getEnabledModels: () => {
const enabled = manager.getGlobalSettings().enabledModels;
return Array.isArray(enabled) &&
enabled.every((pattern) => typeof pattern === "string")
? enabled
: undefined;
},
setEnabledModels: () => {
throw new Error("Scoped model persistence is not supported on omp.");
},
};
}
export function createScopedModelPatternPersister(deps: {
createSettingsManager: (cwd: string) => PiSettingsManager;
clearCachedModelMenuInputs: () => void;
}): (patterns: string[], ctx: ExtensionContext) => Promise<void> {
return async (patterns, ctx) => {
const settingsManager = deps.createSettingsManager(ctx.cwd);
settingsManager.setEnabledModels(
patterns.length > 0 ? patterns : undefined,
);
await settingsManager.flush();
deps.clearCachedModelMenuInputs();
};
}
interface OmpModelQuery {
current: () => ExtensionContext["model"];
}
function hasOmpModelQuery(
ctx: ExtensionContext,
): ctx is ExtensionContext & { models: OmpModelQuery } {
return (
"models" in ctx &&
typeof ctx.models === "object" &&
ctx.models !== null &&
"current" in ctx.models &&
typeof ctx.models.current === "function"
);
}
/**
* omp builds command contexts by spreading a base context, which evaluates the
* `model` getter once: a context stored from `/telegram-connect` would report
* the model of that moment forever. omp's `ctx.models.current()` closes over
* the live session model, so prefer it when present.
*/
export function getExtensionContextModel(
ctx: ExtensionContext,
): ExtensionContext["model"] {
return hasOmpModelQuery(ctx) ? ctx.models.current() : ctx.model;
}
export function getExtensionContextCwd(ctx: ExtensionContext): string {
return ctx.cwd;
}
/**
* True when the extension API belongs to omp. omp's `ExtensionAPI` carries
* `typebox` and `pi` module facades that upstream Pi's API does not have.
*/
export function isOmpHost(api: ExtensionAPI): boolean {
return "typebox" in api && "pi" in api;
}
/**
* omp marks an `agent_end` with `willContinue: true` whenever it already
* scheduled a retry, compaction continuation, todo/plan reminder, or async
* wake. Any other `agent_end` is terminal, which is what Pi reports through
* `agent_settled`.
*/
export function isTerminalAgentEnd(event: AgentEndEvent): boolean {
return !("willContinue" in event && event.willContinue === true);
}
/** Show a one-off toast in the host UI; a no-op where the host has no UI. */
export function notifyExtensionContext(
ctx: ExtensionContext,
message: string,
level: "info" | "warning" | "error",
): void {
ctx.ui.notify(message, level);
}
/**
* Replace the host spinner text, or restore its default with `undefined`.
* Pi-compatible hosts do not all expose `setWorkingMessage`; the spinner text
* is cosmetic, so a host without it is skipped instead of failing the turn.
*/
export function setExtensionWorkingMessage(
ctx: ExtensionContext,
message?: string,
): void {
ctx.ui.setWorkingMessage?.(message);
}
export function isExtensionContextIdle(ctx: ExtensionContext): boolean {
return ctx.isIdle();
}
export function hasExtensionContextPendingMessages(
ctx: ExtensionContext,
): boolean {
return ctx.hasPendingMessages();
}
export function compactExtensionContext(
ctx: ExtensionContext,
callbacks: Parameters<ExtensionContext["compact"]>[0],
): ReturnType<ExtensionContext["compact"]> {
return ctx.compact(callbacks);
}