Validate / validate (push) Successful in 9m52s
- the poll loop now clears its error status after the next successful getUpdates, not before the retry, so a slow retry never shows as healthy - recovery is reported only when getUpdates itself failed - a send that may have reached Telegram is reported as unconfirmed instead of failed - README and CHANGELOG describe the states, toasts, queue preview and spinner text
1580 lines
52 KiB
TypeScript
1580 lines
52 KiB
TypeScript
/**
|
|
* Telegram status rendering helpers
|
|
* Zones: telegram ui, pi agent diagnostics, tui
|
|
* Builds usage, cost, and context summaries for the interactive Telegram status view
|
|
*/
|
|
|
|
/** Message counters the status shows; structurally satisfied by the traffic domain. */
|
|
export interface TelegramStatusTraffic {
|
|
received: number;
|
|
sent: number;
|
|
/** Deliveries that failed after the transport retries; absent means none. */
|
|
failed?: number;
|
|
lastAtMs?: number;
|
|
}
|
|
|
|
export type TelegramStatusQueueLane = "control" | "priority" | "default";
|
|
|
|
export interface TelegramUsageStats {
|
|
totalInput: number;
|
|
totalOutput: number;
|
|
totalCacheRead: number;
|
|
totalCacheWrite: number;
|
|
totalCost: number;
|
|
}
|
|
|
|
interface TelegramUsageMessage {
|
|
role: string;
|
|
usage?: {
|
|
input: number;
|
|
output: number;
|
|
cacheRead: number;
|
|
cacheWrite: number;
|
|
cost: { total: number };
|
|
};
|
|
}
|
|
|
|
interface TelegramStatusSessionEntry {
|
|
type: string;
|
|
message?: TelegramUsageMessage;
|
|
}
|
|
|
|
interface TelegramContextUsage {
|
|
contextWindow?: number;
|
|
percent: number | null;
|
|
}
|
|
|
|
export interface TelegramStatusActiveModel {
|
|
provider?: string;
|
|
id?: string;
|
|
contextWindow?: number;
|
|
}
|
|
|
|
export interface TelegramStatusLineProviderContext {
|
|
activeModel: TelegramStatusActiveModel | undefined;
|
|
}
|
|
|
|
export interface TelegramStatusLineProviderResult {
|
|
label: string;
|
|
value: string;
|
|
}
|
|
|
|
export type TelegramStatusLineProvider = (
|
|
ctx: TelegramStatusLineProviderContext,
|
|
) => TelegramStatusLineProviderResult | undefined;
|
|
|
|
export interface TelegramStatusContext {
|
|
sessionManager: { getEntries(): TelegramStatusSessionEntry[] };
|
|
getContextUsage(): TelegramContextUsage | undefined;
|
|
isIdle?: () => boolean;
|
|
hasPendingMessages?: () => boolean;
|
|
isCompactionInProgress?: () => boolean;
|
|
modelRegistry: {
|
|
isUsingOAuth(model: TelegramStatusActiveModel): boolean;
|
|
};
|
|
}
|
|
|
|
export type TelegramRuntimeEventDetailValue = string | number | boolean | null;
|
|
|
|
const TELEGRAM_STATUS_LINE_PROVIDER_REGISTRY_KEY =
|
|
"__piTelegramStatusLineProviders__";
|
|
const MAX_RECENT_TELEGRAM_RUNTIME_EVENTS = 10;
|
|
const MAX_TELEGRAM_RUNTIME_EVENT_MESSAGE_LENGTH = 1000;
|
|
const MAX_TELEGRAM_RUNTIME_EVENT_DETAIL_LENGTH = 1000;
|
|
|
|
export interface TelegramRuntimeEvent {
|
|
at: number;
|
|
category: string;
|
|
message: string;
|
|
details?: Record<string, TelegramRuntimeEventDetailValue>;
|
|
}
|
|
|
|
export interface TelegramRuntimeEventInput {
|
|
category: string;
|
|
error?: unknown;
|
|
message?: string;
|
|
details?: Record<string, unknown>;
|
|
}
|
|
|
|
export interface TelegramRuntimeEventRecorder {
|
|
record: (
|
|
category: string,
|
|
error: unknown,
|
|
details?: Record<string, unknown>,
|
|
) => void;
|
|
getEvents: () => TelegramRuntimeEvent[];
|
|
clear: () => void;
|
|
}
|
|
|
|
export interface TelegramRuntimeEventRecorderOptions {
|
|
getBotToken: () => string | undefined;
|
|
maxEvents?: number;
|
|
now?: () => number;
|
|
}
|
|
|
|
export interface TelegramBridgeStatusBusFollower {
|
|
instanceId: string;
|
|
cwd?: string;
|
|
lastHeartbeatMs: number;
|
|
target?: { chatId: number; threadId?: number };
|
|
slot?: string;
|
|
threadName?: string;
|
|
status?: string;
|
|
}
|
|
|
|
export interface TelegramBridgeStatusLocalBus {
|
|
leaderSocketPath?: string;
|
|
leaderTransport?: "pipe" | "socket";
|
|
followerSocketPath?: string;
|
|
followerTransport?: "pipe" | "socket";
|
|
followerRegistered?: boolean;
|
|
followerTarget?: { chatId: number; threadId?: number };
|
|
followerSlot?: string;
|
|
followerThreadName?: string;
|
|
}
|
|
|
|
export interface TelegramBridgeStatusTopicTarget {
|
|
instanceId?: string;
|
|
status?: string;
|
|
target?: { chatId: number; threadId?: number };
|
|
slot?: string;
|
|
threadName?: string;
|
|
syncStatus?: string;
|
|
lastSyncObservedAtMs?: number;
|
|
lastSyncProbeAtMs?: number;
|
|
lastSyncError?: string;
|
|
lastReconcileAction?: string;
|
|
}
|
|
|
|
export interface TelegramBridgeStatusThreadReservation {
|
|
target?: { chatId: number; threadId?: number };
|
|
slot?: string;
|
|
reason?: string;
|
|
instanceId?: string;
|
|
expiresAtMs?: number;
|
|
lastReconcileAction?: string;
|
|
}
|
|
|
|
export interface TelegramBridgeStatusSyncObservation {
|
|
target?: { chatId: number; threadId?: number };
|
|
syncStatus: string;
|
|
observedAtMs: number;
|
|
instanceId?: string;
|
|
slot?: string;
|
|
lastSyncError?: string;
|
|
lastReconcileAction?: string;
|
|
}
|
|
|
|
export interface TelegramBridgeStatusSyncSlice {
|
|
status: string;
|
|
updatedAtMs?: number;
|
|
suspectAtMs?: number;
|
|
reason?: string;
|
|
lastReconcileAction?: string;
|
|
}
|
|
|
|
export interface TelegramBridgeThreadReconciliationState {
|
|
phase: string;
|
|
event: string;
|
|
atMs: number;
|
|
leaderEpoch?: number | string;
|
|
pendingProvisionCount: number;
|
|
syncActionCount: number;
|
|
cleanupActionCount: number;
|
|
}
|
|
|
|
export type TelegramBridgeBusRole = "leader" | "follower";
|
|
export type TelegramBridgeBusLifecyclePhase = "electing";
|
|
|
|
export interface TelegramBridgeStatusLineState {
|
|
hasBotToken?: boolean;
|
|
botUsername?: string;
|
|
activeProfileName?: string;
|
|
diagnosticPaths?: { state: string; logs: string };
|
|
allowedUserId?: number;
|
|
botThreadMode?: "unknown" | "enabled" | "disabled";
|
|
botThreadModeUpdatedAtMs?: number;
|
|
botThreadModeAction?: string;
|
|
busRole?: TelegramBridgeBusRole;
|
|
busLifecyclePhase?: TelegramBridgeBusLifecyclePhase;
|
|
instanceSlot?: string;
|
|
instanceThreadName?: string;
|
|
lockState?: string;
|
|
traffic?: TelegramStatusTraffic;
|
|
pollingActive: boolean;
|
|
lastUpdateId?: number;
|
|
activeSourceMessageIds?: number[];
|
|
pendingDispatch: boolean;
|
|
compactionInProgress: boolean;
|
|
activeToolExecutions: number;
|
|
pendingModelSwitch: boolean;
|
|
queuedItems: Array<{ queueLane: TelegramStatusQueueLane }>;
|
|
busFollowers?: TelegramBridgeStatusBusFollower[];
|
|
localBus?: TelegramBridgeStatusLocalBus;
|
|
topicTargets?: TelegramBridgeStatusTopicTarget[];
|
|
threadReservations?: TelegramBridgeStatusThreadReservation[];
|
|
topicSyncObservations?: TelegramBridgeStatusSyncObservation[];
|
|
syncState?: Record<string, TelegramBridgeStatusSyncSlice | undefined>;
|
|
threadReconciliation?: TelegramBridgeThreadReconciliationState;
|
|
busNowMs?: number;
|
|
recentRuntimeEvents: TelegramRuntimeEvent[];
|
|
}
|
|
|
|
export interface TelegramStatusBarTheme {
|
|
fg: (
|
|
token: "accent" | "error" | "muted" | "warning" | "success",
|
|
text: string,
|
|
) => string;
|
|
/**
|
|
* omp symbol presets (unicode, nerd font, ascii) so the status follows the
|
|
* user's glyph set. Hosts that do not expose them get the unicode fallbacks.
|
|
*/
|
|
status?: {
|
|
enabled?: string;
|
|
shadowed?: string;
|
|
running?: string;
|
|
warning?: string;
|
|
error?: string;
|
|
disabled?: string;
|
|
};
|
|
sep?: { dot?: string };
|
|
icon?: { input?: string; output?: string; time?: string };
|
|
}
|
|
|
|
export interface TelegramStatusBarState {
|
|
hasBotToken: boolean;
|
|
pollingActive: boolean;
|
|
paired: boolean;
|
|
botUsername?: string;
|
|
traffic?: TelegramStatusTraffic;
|
|
busRole?: TelegramBridgeBusRole;
|
|
busLifecyclePhase?: TelegramBridgeBusLifecyclePhase;
|
|
instanceSlot?: string;
|
|
instanceThreadName?: string;
|
|
compactionInProgress: boolean;
|
|
processing: boolean;
|
|
processingStatus?: string;
|
|
queuedStatus: string;
|
|
/** Summaries of the queued turns, in dispatch order. */
|
|
queuedPreview?: string[];
|
|
error?: string;
|
|
}
|
|
|
|
export interface TelegramStatusRuntimeContext {
|
|
/** Host run mode; the colored widget is only used in the interactive TUI. */
|
|
mode?: string;
|
|
ui: {
|
|
theme: TelegramStatusBarTheme;
|
|
setStatus(key: string, text: string | undefined): void;
|
|
setWidget?(
|
|
key: string,
|
|
content: string[] | undefined,
|
|
options?: { placement?: "aboveEditor" | "belowEditor" },
|
|
): void;
|
|
};
|
|
}
|
|
|
|
export interface TelegramStatusRuntimeDeps<
|
|
TContext extends TelegramStatusRuntimeContext,
|
|
> {
|
|
statusKey?: string;
|
|
getStatusBarState: (ctx: TContext, error?: string) => TelegramStatusBarState;
|
|
getBridgeStatusLineState: () => TelegramBridgeStatusLineState;
|
|
}
|
|
|
|
export interface TelegramBridgeStatusConfig {
|
|
botToken?: string;
|
|
botUsername?: string;
|
|
allowedUserId?: number;
|
|
lastUpdateId?: number;
|
|
}
|
|
|
|
export interface TelegramBridgeStatusRuntimeDeps<
|
|
TQueueItem extends { queueLane: TelegramStatusQueueLane },
|
|
> {
|
|
statusKey?: string;
|
|
getConfig: () => TelegramBridgeStatusConfig;
|
|
getActiveProfileName?: () => string | undefined;
|
|
getDiagnosticPaths?: (
|
|
profileName?: string,
|
|
) => { state: string; logs: string };
|
|
isPollingActive: () => boolean;
|
|
getActiveSourceMessageIds: () => number[] | undefined;
|
|
hasActiveTurn: () => boolean;
|
|
hasDispatchPending: () => boolean;
|
|
isCompactionInProgress: () => boolean;
|
|
getActiveToolExecutions: () => number;
|
|
hasPendingModelSwitch: () => boolean;
|
|
getQueuedItems: () => TQueueItem[];
|
|
formatQueuedStatus: (items: TQueueItem[]) => string;
|
|
getQueuedPreview?: (items: TQueueItem[]) => string[];
|
|
getRecentRuntimeEvents: () => TelegramRuntimeEvent[];
|
|
getRuntimeLockState?: () => string;
|
|
getBusRole?: () => TelegramBridgeBusRole | undefined;
|
|
getBusLifecyclePhase?: () => TelegramBridgeBusLifecyclePhase | undefined;
|
|
getBotThreadMode?: () =>
|
|
| {
|
|
threadMode: "unknown" | "enabled" | "disabled";
|
|
updatedAtMs?: number;
|
|
lastReconcileAction?: string;
|
|
}
|
|
| undefined;
|
|
getBusFollowers?: () => TelegramBridgeStatusBusFollower[];
|
|
getLocalBus?: () => TelegramBridgeStatusLocalBus | undefined;
|
|
getTopicTargets?: () => TelegramBridgeStatusTopicTarget[];
|
|
getThreadReservations?: () => TelegramBridgeStatusThreadReservation[];
|
|
getTopicSyncObservations?: () => TelegramBridgeStatusSyncObservation[];
|
|
getSyncState?: () => Record<
|
|
string,
|
|
TelegramBridgeStatusSyncSlice | undefined
|
|
>;
|
|
getThreadReconciliationState?: () =>
|
|
| TelegramBridgeThreadReconciliationState
|
|
| undefined;
|
|
getInstanceSlot?: () => string | undefined;
|
|
getInstanceThreadName?: () => string | undefined;
|
|
getNowMs?: () => number;
|
|
getTraffic?: () => TelegramStatusTraffic;
|
|
}
|
|
|
|
export interface TelegramBridgeStatusLineOptions {
|
|
verbose?: boolean;
|
|
}
|
|
|
|
export interface TelegramStatusRuntime<
|
|
TContext extends TelegramStatusRuntimeContext,
|
|
> {
|
|
updateStatus: (ctx: TContext, error?: string) => void;
|
|
getStatusLines: (options?: TelegramBridgeStatusLineOptions) => string[];
|
|
getStatusState: () => TelegramBridgeStatusLineState;
|
|
}
|
|
|
|
function truncateTelegramRuntimeEventText(
|
|
text: string,
|
|
maxLength: number,
|
|
): string {
|
|
if (text.length <= maxLength) return text;
|
|
return `${text.slice(0, maxLength).trimEnd()}… [truncated ${text.length - maxLength} chars]`;
|
|
}
|
|
|
|
export function redactTelegramRuntimeMessage(
|
|
message: string,
|
|
botToken: string | undefined,
|
|
): string {
|
|
const redacted = botToken
|
|
? message.split(botToken).join("<redacted-token>")
|
|
: message;
|
|
return truncateTelegramRuntimeEventText(
|
|
redacted,
|
|
MAX_TELEGRAM_RUNTIME_EVENT_MESSAGE_LENGTH,
|
|
);
|
|
}
|
|
|
|
function redactTelegramRuntimeDetail(
|
|
message: string,
|
|
botToken: string | undefined,
|
|
): string {
|
|
const redacted = botToken
|
|
? message.split(botToken).join("<redacted-token>")
|
|
: message;
|
|
return truncateTelegramRuntimeEventText(
|
|
redacted,
|
|
MAX_TELEGRAM_RUNTIME_EVENT_DETAIL_LENGTH,
|
|
);
|
|
}
|
|
|
|
function normalizeTelegramRuntimeEventDetails(
|
|
details: Record<string, unknown> | undefined,
|
|
botToken: string | undefined,
|
|
): Record<string, TelegramRuntimeEventDetailValue> | undefined {
|
|
if (!details) return undefined;
|
|
const normalized: Record<string, TelegramRuntimeEventDetailValue> = {};
|
|
for (const [key, value] of Object.entries(details)) {
|
|
if (value === undefined) continue;
|
|
if (typeof value === "string") {
|
|
normalized[key] = redactTelegramRuntimeDetail(value, botToken);
|
|
continue;
|
|
}
|
|
if (typeof value === "number" || typeof value === "boolean") {
|
|
normalized[key] = value;
|
|
continue;
|
|
}
|
|
if (value === null) {
|
|
normalized[key] = null;
|
|
continue;
|
|
}
|
|
normalized[key] = redactTelegramRuntimeDetail(String(value), botToken);
|
|
}
|
|
return Object.keys(normalized).length > 0 ? normalized : undefined;
|
|
}
|
|
|
|
function getTelegramRuntimeEventMessage(
|
|
input: TelegramRuntimeEventInput,
|
|
): string {
|
|
if (input.message !== undefined) return input.message;
|
|
if (input.error instanceof Error) return input.error.message;
|
|
return String(input.error);
|
|
}
|
|
|
|
export function recordStructuredTelegramRuntimeEvent(
|
|
events: TelegramRuntimeEvent[],
|
|
input: TelegramRuntimeEventInput,
|
|
options: { botToken?: string; maxEvents: number; now?: number },
|
|
): void {
|
|
const details = normalizeTelegramRuntimeEventDetails(
|
|
input.details,
|
|
options.botToken,
|
|
);
|
|
events.push({
|
|
at: options.now ?? Date.now(),
|
|
category: input.category,
|
|
message: redactTelegramRuntimeMessage(
|
|
getTelegramRuntimeEventMessage(input),
|
|
options.botToken,
|
|
),
|
|
...(details ? { details } : {}),
|
|
});
|
|
while (events.length > options.maxEvents) {
|
|
events.shift();
|
|
}
|
|
}
|
|
|
|
export function recordTelegramRuntimeEvent(
|
|
events: TelegramRuntimeEvent[],
|
|
category: string,
|
|
error: unknown,
|
|
options: { botToken?: string; maxEvents: number; now?: number },
|
|
): void {
|
|
recordStructuredTelegramRuntimeEvent(events, { category, error }, options);
|
|
}
|
|
|
|
function getOrCreateTelegramStatusLineProviderRegistry(): Map<
|
|
string,
|
|
TelegramStatusLineProvider
|
|
> {
|
|
const existing = (globalThis as Record<string, unknown>)[
|
|
TELEGRAM_STATUS_LINE_PROVIDER_REGISTRY_KEY
|
|
];
|
|
if (existing instanceof Map)
|
|
return existing as Map<string, TelegramStatusLineProvider>;
|
|
const registry = new Map<string, TelegramStatusLineProvider>();
|
|
(globalThis as Record<string, unknown>)[
|
|
TELEGRAM_STATUS_LINE_PROVIDER_REGISTRY_KEY
|
|
] = registry;
|
|
return registry;
|
|
}
|
|
|
|
/**
|
|
* Register a compact extension-provided line for the Telegram status menu.
|
|
*
|
|
* Providers are synchronous and should return undefined when their line is not
|
|
* relevant for the active model. Errors are isolated so optional extension
|
|
* status cannot break the core Telegram menu.
|
|
*/
|
|
export function registerTelegramStatusLineProvider(
|
|
provider: TelegramStatusLineProvider,
|
|
options: { id: string },
|
|
): () => void {
|
|
const registry = getOrCreateTelegramStatusLineProviderRegistry();
|
|
registry.set(options.id, provider);
|
|
return () => {
|
|
if (registry.get(options.id) === provider) registry.delete(options.id);
|
|
};
|
|
}
|
|
|
|
export function getTelegramStatusLineProviderResults(
|
|
ctx: TelegramStatusLineProviderContext,
|
|
): TelegramStatusLineProviderResult[] {
|
|
const results: TelegramStatusLineProviderResult[] = [];
|
|
const registry = getOrCreateTelegramStatusLineProviderRegistry();
|
|
for (const provider of registry.values()) {
|
|
try {
|
|
const result = provider(ctx);
|
|
if (!result?.label || !result.value) continue;
|
|
results.push(result);
|
|
} catch {
|
|
continue;
|
|
}
|
|
}
|
|
return results;
|
|
}
|
|
|
|
export function clearTelegramStatusLineProviders(): void {
|
|
getOrCreateTelegramStatusLineProviderRegistry().clear();
|
|
}
|
|
|
|
export function createTelegramRuntimeEventRecorder(
|
|
options: TelegramRuntimeEventRecorderOptions,
|
|
): TelegramRuntimeEventRecorder {
|
|
const events: TelegramRuntimeEvent[] = [];
|
|
return {
|
|
record: (category, error, details) => {
|
|
recordStructuredTelegramRuntimeEvent(
|
|
events,
|
|
{ category, error, details },
|
|
{
|
|
botToken: options.getBotToken(),
|
|
maxEvents: options.maxEvents ?? MAX_RECENT_TELEGRAM_RUNTIME_EVENTS,
|
|
now: options.now?.(),
|
|
},
|
|
);
|
|
},
|
|
getEvents: () => events,
|
|
clear: () => {
|
|
events.length = 0;
|
|
},
|
|
};
|
|
}
|
|
|
|
function formatTelegramRuntimeEventCategory(
|
|
event: TelegramRuntimeEvent,
|
|
): string {
|
|
const method = event.details?.method;
|
|
return typeof method === "string"
|
|
? `${event.category}:${method}`
|
|
: event.category;
|
|
}
|
|
|
|
function formatTelegramRuntimeEventDetails(
|
|
event: TelegramRuntimeEvent,
|
|
): string {
|
|
if (!event.details) return "";
|
|
const details = Object.entries(event.details)
|
|
.filter(([key]) => key !== "method")
|
|
.map(([key, value]) => `${key}=${JSON.stringify(value)}`);
|
|
return details.length > 0 ? ` (${details.join(", ")})` : "";
|
|
}
|
|
|
|
function formatTelegramRuntimeEventSummary(
|
|
event: TelegramRuntimeEvent,
|
|
): string {
|
|
return `${formatTelegramRuntimeEventCategory(event)}: ${event.message}${formatTelegramRuntimeEventDetails(event)}`;
|
|
}
|
|
|
|
function formatTelegramRuntimeEvent(event: TelegramRuntimeEvent): string {
|
|
return `${new Date(event.at).toISOString()} ${formatTelegramRuntimeEventSummary(event)}`;
|
|
}
|
|
|
|
function buildTelegramRuntimeEventSummary(
|
|
events: TelegramRuntimeEvent[],
|
|
): string {
|
|
const counts = new Map<string, number>();
|
|
for (const event of events) {
|
|
const category = formatTelegramRuntimeEventCategory(event);
|
|
counts.set(category, (counts.get(category) ?? 0) + 1);
|
|
}
|
|
return Array.from(counts.entries())
|
|
.sort(([left], [right]) => left.localeCompare(right))
|
|
.map(([category, count]) => `${category}=${count}`)
|
|
.join(", ");
|
|
}
|
|
|
|
export function buildTelegramRuntimeEventLines(
|
|
events: TelegramRuntimeEvent[],
|
|
): string[] {
|
|
if (events.length === 0) return ["recent runtime events: none"];
|
|
return [
|
|
"recent runtime events:",
|
|
`- summary: ${buildTelegramRuntimeEventSummary(events)}`,
|
|
...events
|
|
.slice()
|
|
.reverse()
|
|
.map((event) => `- ${formatTelegramRuntimeEvent(event)}`),
|
|
];
|
|
}
|
|
|
|
export function createTelegramStatusHtmlBuilder<TContext>(deps: {
|
|
getActiveModel: (ctx: TContext) => TelegramStatusActiveModel | undefined;
|
|
isCompactionInProgress?: () => boolean;
|
|
getBridgeStatusLineState?: () => TelegramBridgeStatusLineState;
|
|
}): (ctx: TContext & TelegramStatusContext) => string {
|
|
return (ctx) =>
|
|
buildStatusHtml(
|
|
{ ...ctx, isCompactionInProgress: deps.isCompactionInProgress },
|
|
deps.getActiveModel(ctx),
|
|
deps.getBridgeStatusLineState?.(),
|
|
);
|
|
}
|
|
|
|
export function createTelegramStatusRuntime<
|
|
TContext extends TelegramStatusRuntimeContext,
|
|
>(deps: TelegramStatusRuntimeDeps<TContext>): TelegramStatusRuntime<TContext> {
|
|
const statusKey = deps.statusKey ?? "telegram";
|
|
return {
|
|
updateStatus: (ctx, error) => {
|
|
const lines = buildTelegramStatusBarLines(
|
|
ctx.ui.theme,
|
|
deps.getStatusBarState(ctx, error),
|
|
);
|
|
if (ctx.mode === "tui" && ctx.ui.setWidget) {
|
|
// The host strips ANSI from status text, so setStatus would drop the
|
|
// theme colors. String widgets keep them; clear the plain footer entry
|
|
// so the state is not shown twice.
|
|
ctx.ui.setWidget(statusKey, lines, { placement: "belowEditor" });
|
|
ctx.ui.setStatus(statusKey, undefined);
|
|
return;
|
|
}
|
|
ctx.ui.setStatus(statusKey, lines[0]);
|
|
},
|
|
getStatusLines: (options) =>
|
|
buildTelegramBridgeStatusLines(deps.getBridgeStatusLineState(), options),
|
|
getStatusState: deps.getBridgeStatusLineState,
|
|
};
|
|
}
|
|
|
|
export function createTelegramBridgeStatusRuntime<
|
|
TContext extends TelegramStatusRuntimeContext,
|
|
TQueueItem extends { queueLane: TelegramStatusQueueLane },
|
|
>(
|
|
deps: TelegramBridgeStatusRuntimeDeps<TQueueItem>,
|
|
): TelegramStatusRuntime<TContext> {
|
|
return createTelegramStatusRuntime({
|
|
statusKey: deps.statusKey,
|
|
getStatusBarState: (_ctx, error) => {
|
|
const config = deps.getConfig();
|
|
const queuedItems = deps.getQueuedItems();
|
|
const hasActiveTurn = deps.hasActiveTurn();
|
|
const hasPendingDispatch = deps.hasDispatchPending();
|
|
const hasPendingModelSwitch = deps.hasPendingModelSwitch();
|
|
const activeToolExecutions = deps.getActiveToolExecutions();
|
|
const compactionInProgress = deps.isCompactionInProgress();
|
|
return {
|
|
hasBotToken: !!config.botToken,
|
|
pollingActive: deps.isPollingActive(),
|
|
paired: !!config.allowedUserId,
|
|
botUsername: config.botUsername,
|
|
traffic: deps.getTraffic?.(),
|
|
busRole: deps.getBusRole?.(),
|
|
busLifecyclePhase: deps.getBusLifecyclePhase?.(),
|
|
instanceSlot: deps.getInstanceSlot?.(),
|
|
instanceThreadName: deps.getInstanceThreadName?.(),
|
|
compactionInProgress,
|
|
processing:
|
|
hasActiveTurn ||
|
|
hasPendingDispatch ||
|
|
hasPendingModelSwitch ||
|
|
activeToolExecutions > 0 ||
|
|
queuedItems.length > 0,
|
|
processingStatus: getTelegramStatusBarProcessingStatus({
|
|
hasActiveTurn,
|
|
hasPendingDispatch,
|
|
hasPendingModelSwitch,
|
|
activeToolExecutions,
|
|
queuedItems: queuedItems.length,
|
|
}),
|
|
queuedStatus: deps.formatQueuedStatus(queuedItems),
|
|
queuedPreview: deps.getQueuedPreview?.(queuedItems),
|
|
error,
|
|
};
|
|
},
|
|
getBridgeStatusLineState: () => {
|
|
const config = deps.getConfig();
|
|
const botThreadMode = deps.getBotThreadMode?.();
|
|
const activeProfileName = deps.getActiveProfileName?.();
|
|
return {
|
|
hasBotToken: Boolean(config.botToken),
|
|
botUsername: config.botUsername,
|
|
activeProfileName,
|
|
diagnosticPaths: deps.getDiagnosticPaths?.(activeProfileName),
|
|
allowedUserId: config.allowedUserId,
|
|
botThreadMode: botThreadMode?.threadMode,
|
|
botThreadModeUpdatedAtMs: botThreadMode?.updatedAtMs,
|
|
botThreadModeAction: botThreadMode?.lastReconcileAction,
|
|
busRole: deps.getBusRole?.(),
|
|
busLifecyclePhase: deps.getBusLifecyclePhase?.(),
|
|
instanceSlot: deps.getInstanceSlot?.(),
|
|
instanceThreadName: deps.getInstanceThreadName?.(),
|
|
lockState: deps.getRuntimeLockState?.(),
|
|
traffic: deps.getTraffic?.(),
|
|
pollingActive: deps.isPollingActive(),
|
|
lastUpdateId: config.lastUpdateId,
|
|
activeSourceMessageIds: deps.getActiveSourceMessageIds(),
|
|
pendingDispatch: deps.hasDispatchPending(),
|
|
compactionInProgress: deps.isCompactionInProgress(),
|
|
activeToolExecutions: deps.getActiveToolExecutions(),
|
|
pendingModelSwitch: deps.hasPendingModelSwitch(),
|
|
queuedItems: deps.getQueuedItems(),
|
|
busFollowers: deps.getBusFollowers?.(),
|
|
localBus: deps.getLocalBus?.(),
|
|
topicTargets: deps.getTopicTargets?.(),
|
|
threadReservations: deps.getThreadReservations?.(),
|
|
topicSyncObservations: deps.getTopicSyncObservations?.(),
|
|
syncState: deps.getSyncState?.(),
|
|
threadReconciliation: deps.getThreadReconciliationState?.(),
|
|
busNowMs: deps.getNowMs?.(),
|
|
recentRuntimeEvents: deps.getRecentRuntimeEvents(),
|
|
};
|
|
},
|
|
});
|
|
}
|
|
|
|
export interface TelegramRuntimeLogScope extends Record<string, unknown> {
|
|
instanceId: string;
|
|
role: string;
|
|
slot?: string;
|
|
threadName?: string;
|
|
lockState?: string;
|
|
}
|
|
|
|
export function createTelegramRuntimeLogScope(input: {
|
|
state: TelegramBridgeStatusLineState;
|
|
instanceId: string;
|
|
}): TelegramRuntimeLogScope {
|
|
return {
|
|
instanceId: input.instanceId,
|
|
role: input.state.busRole ?? "classic-or-disconnected",
|
|
slot: input.state.instanceSlot,
|
|
threadName: input.state.instanceThreadName,
|
|
lockState: input.state.lockState,
|
|
};
|
|
}
|
|
|
|
export function createTelegramStatusSnapshot(
|
|
state: TelegramBridgeStatusLineState,
|
|
): {
|
|
runtime: Record<string, unknown>;
|
|
liveRoster: Record<string, unknown>;
|
|
diagnostics: Record<string, unknown>;
|
|
} {
|
|
return {
|
|
runtime: {
|
|
busRole: state.busRole,
|
|
...(state.busLifecyclePhase
|
|
? { busLifecyclePhase: state.busLifecyclePhase }
|
|
: {}),
|
|
botThreadMode: state.botThreadMode,
|
|
botThreadModeUpdatedAtMs: state.botThreadModeUpdatedAtMs,
|
|
botThreadModeAction: state.botThreadModeAction,
|
|
instanceSlot: state.instanceSlot,
|
|
instanceThreadName: state.instanceThreadName,
|
|
pollingActive: state.pollingActive,
|
|
lockState: state.lockState,
|
|
},
|
|
liveRoster: {
|
|
busFollowers: state.busFollowers ?? [],
|
|
...(state.localBus ? { localBus: state.localBus } : {}),
|
|
topicTargets: state.topicTargets ?? [],
|
|
reservations: state.threadReservations ?? [],
|
|
syncObservations: state.topicSyncObservations ?? [],
|
|
},
|
|
diagnostics: {
|
|
pendingDispatch: state.pendingDispatch,
|
|
compactionInProgress: state.compactionInProgress,
|
|
activeToolExecutions: state.activeToolExecutions,
|
|
pendingModelSwitch: state.pendingModelSwitch,
|
|
syncState: state.syncState,
|
|
threadReconciliation: state.threadReconciliation,
|
|
recentRuntimeEvents: state.recentRuntimeEvents,
|
|
},
|
|
};
|
|
}
|
|
|
|
export function createTelegramRuntimeDiagnosticsSnapshotScheduler(deps: {
|
|
persistSnapshot: () => Promise<void>;
|
|
recordError: (error: unknown) => void;
|
|
setTimer?: (callback: () => void, ms: number) => { unref?: () => void };
|
|
}): () => void {
|
|
const setTimer = deps.setTimer ?? setTimeout;
|
|
let timer: { unref?: () => void } | number | undefined;
|
|
return () => {
|
|
if (timer) return;
|
|
timer = setTimer(() => {
|
|
timer = undefined;
|
|
void deps.persistSnapshot().catch(deps.recordError);
|
|
}, 0);
|
|
if (typeof timer !== "number") timer?.unref?.();
|
|
};
|
|
}
|
|
|
|
export function getTelegramStatusBarProcessingStatus(state: {
|
|
hasActiveTurn: boolean;
|
|
hasPendingDispatch: boolean;
|
|
hasPendingModelSwitch: boolean;
|
|
activeToolExecutions: number;
|
|
queuedItems: number;
|
|
}): string | undefined {
|
|
if (state.hasPendingModelSwitch) return "model";
|
|
if (state.hasActiveTurn || state.activeToolExecutions > 0) return "active";
|
|
if (state.hasPendingDispatch) return "dispatching";
|
|
if (state.queuedItems > 0) return "queued";
|
|
return undefined;
|
|
}
|
|
|
|
function getTelegramStatusBarLabel(state: TelegramStatusBarState): string {
|
|
const threadName = state.instanceThreadName?.trim();
|
|
if (!threadName) return "telegram";
|
|
const genericLabels = new Set(["telegram", "leader", "follower"]);
|
|
if (genericLabels.has(threadName.toLowerCase())) return "telegram";
|
|
return threadName;
|
|
}
|
|
|
|
interface TelegramStatusBarGlyphs {
|
|
enabled: string;
|
|
shadowed: string;
|
|
running: string;
|
|
warning: string;
|
|
error: string;
|
|
disabled: string;
|
|
dot: string;
|
|
input: string;
|
|
output: string;
|
|
time: string;
|
|
}
|
|
|
|
function resolveTelegramStatusBarGlyphs(
|
|
theme: TelegramStatusBarTheme,
|
|
): TelegramStatusBarGlyphs {
|
|
return {
|
|
enabled: theme.status?.enabled ?? "●",
|
|
shadowed: theme.status?.shadowed ?? "○",
|
|
running: theme.status?.running ?? "⟳",
|
|
warning: theme.status?.warning ?? "⚠",
|
|
error: theme.status?.error ?? "✘",
|
|
disabled: theme.status?.disabled ?? "⦸",
|
|
dot: theme.sep?.dot ?? " · ",
|
|
input: theme.icon?.input ?? "↓",
|
|
output: theme.icon?.output ?? "↑",
|
|
time: theme.icon?.time ?? "⏱",
|
|
};
|
|
}
|
|
|
|
function formatTelegramStatusBarClock(timestampMs: number): string {
|
|
const date = new Date(timestampMs);
|
|
const hours = String(date.getHours()).padStart(2, "0");
|
|
const minutes = String(date.getMinutes()).padStart(2, "0");
|
|
return `${hours}:${minutes}`;
|
|
}
|
|
|
|
/** Bot name, message counters and last activity, each behind a muted dot. */
|
|
function buildTelegramStatusBarDetails(
|
|
theme: TelegramStatusBarTheme,
|
|
glyphs: TelegramStatusBarGlyphs,
|
|
state: TelegramStatusBarState,
|
|
): string {
|
|
const parts: string[] = [];
|
|
if (state.botUsername) {
|
|
parts.push(`@${sanitizeTelegramStatusText(state.botUsername)}`);
|
|
}
|
|
if (state.traffic) {
|
|
const failed = state.traffic.failed
|
|
? ` ${glyphs.error}${state.traffic.failed}`
|
|
: "";
|
|
parts.push(
|
|
`${glyphs.input}${state.traffic.received} ${glyphs.output}${state.traffic.sent}${failed}`,
|
|
);
|
|
if (state.traffic.lastAtMs !== undefined) {
|
|
parts.push(
|
|
`${glyphs.time} ${formatTelegramStatusBarClock(state.traffic.lastAtMs)}`,
|
|
);
|
|
}
|
|
}
|
|
return parts
|
|
.map((part) => theme.fg("muted", `${glyphs.dot}${part}`))
|
|
.join("");
|
|
}
|
|
|
|
function buildTelegramTrafficLines(
|
|
state: Pick<TelegramBridgeStatusLineState, "traffic">,
|
|
): string[] {
|
|
if (!state.traffic) return [];
|
|
const last =
|
|
state.traffic.lastAtMs === undefined
|
|
? ""
|
|
: `, last ${formatTelegramStatusBarClock(state.traffic.lastAtMs)}`;
|
|
return [
|
|
`- messages: ${state.traffic.received} received, ${state.traffic.sent} sent${state.traffic.failed ? `, ${state.traffic.failed} failed` : ""}${last}`,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Strip control characters and bidi overrides. Widget lines bypass the host's
|
|
* status sanitizer, and thread names, bot names, queue summaries and error text
|
|
* can all originate from Telegram, so none may carry terminal escapes.
|
|
*/
|
|
export function sanitizeTelegramStatusText(text: string): string {
|
|
return text
|
|
.replace(
|
|
/[\u0000-\u001f\u007f-\u009f\u200e\u200f\u202a-\u202e\u2066-\u2069]/g,
|
|
" ",
|
|
)
|
|
.replace(/ +/g, " ")
|
|
.trim();
|
|
}
|
|
|
|
export interface TelegramStatusErrorSummary {
|
|
/** Short state text such as "invalid token". */
|
|
label: string;
|
|
/** What the operator can do next, or "retrying" while the bridge retries. */
|
|
hint?: string;
|
|
/** fatal needs the operator, attention needs a look, retrying heals itself. */
|
|
kind: "fatal" | "attention" | "retrying";
|
|
/**
|
|
* The failed call may still have reached Telegram: a non-idempotent send hit
|
|
* a transport failure after the request left, so the outcome is unknown.
|
|
*/
|
|
deliveryUnknown?: boolean;
|
|
}
|
|
|
|
/**
|
|
* Turn a raw transport or API failure into a short state and next step.
|
|
* Returns undefined for anything unrecognized so the raw text stays visible.
|
|
*/
|
|
export function summarizeTelegramStatusError(
|
|
message: string,
|
|
): TelegramStatusErrorSummary | undefined {
|
|
if (/may have committed before transport failed/i.test(message)) {
|
|
return {
|
|
kind: "attention",
|
|
label: "delivery unconfirmed",
|
|
deliveryUnknown: true,
|
|
};
|
|
}
|
|
if (/HTTP 401\b|\bUnauthorized\b/i.test(message)) {
|
|
return { kind: "fatal", label: "invalid token", hint: "/telegram-setup" };
|
|
}
|
|
if (/HTTP 429\b|Too Many Requests|retry[ _]after/i.test(message)) {
|
|
const seconds = /retry[ _]after[:= ]+(\d+)/i.exec(message)?.[1];
|
|
return {
|
|
kind: "retrying",
|
|
label: seconds ? `rate limited ${seconds}s` : "rate limited",
|
|
hint: "retrying",
|
|
};
|
|
}
|
|
if (
|
|
/HTTP 409\b|\bConflict\b|terminated by other getUpdates/i.test(message)
|
|
) {
|
|
return {
|
|
kind: "attention",
|
|
label: "another poller is active",
|
|
hint: "/telegram-status",
|
|
};
|
|
}
|
|
if (
|
|
/HTTP 5\d\d\b|Bad Gateway|Service Unavailable|Internal Server Error/i.test(
|
|
message,
|
|
)
|
|
) {
|
|
return { kind: "retrying", label: "Telegram unavailable", hint: "retrying" };
|
|
}
|
|
if (
|
|
/Unable to connect|ECONNREFUSED|ECONNRESET|ENOTFOUND|EAI_AGAIN|ETIMEDOUT|fetch failed|network|timed? out|socket hang up/i.test(
|
|
message,
|
|
)
|
|
) {
|
|
return { kind: "retrying", label: "offline", hint: "retrying" };
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function formatTelegramStatusBarError(
|
|
theme: TelegramStatusBarTheme,
|
|
glyphs: TelegramStatusBarGlyphs,
|
|
label: string,
|
|
error: string,
|
|
): string {
|
|
const summary = summarizeTelegramStatusError(error);
|
|
if (!summary) {
|
|
const raw = sanitizeTelegramStatusText(error);
|
|
const shown = raw.length > 100 ? `${raw.slice(0, 99)}…` : raw;
|
|
return `${label} ${theme.fg("error", `${glyphs.error} error`)} ${theme.fg("muted", shown)}`;
|
|
}
|
|
const token = summary.kind === "fatal" ? "error" : "warning";
|
|
const glyph =
|
|
summary.kind === "fatal"
|
|
? glyphs.error
|
|
: summary.kind === "retrying"
|
|
? glyphs.running
|
|
: glyphs.warning;
|
|
const hint = summary.hint
|
|
? theme.fg("muted", `${glyphs.dot}${summary.hint}`)
|
|
: "";
|
|
return `${label} ${theme.fg(token, `${glyph} ${summary.label}`)}${hint}`;
|
|
}
|
|
|
|
export function buildTelegramStatusBarText(
|
|
theme: TelegramStatusBarTheme,
|
|
state: TelegramStatusBarState,
|
|
): string {
|
|
const glyphs = resolveTelegramStatusBarGlyphs(theme);
|
|
const label = theme.fg(
|
|
"accent",
|
|
sanitizeTelegramStatusText(getTelegramStatusBarLabel(state)),
|
|
);
|
|
const hint = (text: string) => theme.fg("muted", `${glyphs.dot}${text}`);
|
|
const queued = state.queuedStatus
|
|
? theme.fg("success", state.queuedStatus)
|
|
: "";
|
|
if (state.error) {
|
|
return formatTelegramStatusBarError(theme, glyphs, label, state.error);
|
|
}
|
|
if (!state.hasBotToken) {
|
|
return `${label} ${theme.fg("muted", `${glyphs.disabled} not configured`)}${queued}${hint("/telegram-setup")}`;
|
|
}
|
|
if (!state.paired) {
|
|
const bot = state.botUsername
|
|
? `@${sanitizeTelegramStatusText(state.botUsername)}`
|
|
: "the bot";
|
|
return `${label} ${theme.fg("warning", `${glyphs.warning} awaiting pairing`)}${queued}${hint(`send /start to ${bot}`)}`;
|
|
}
|
|
if (state.busLifecyclePhase === "electing") {
|
|
return `${label} ${theme.fg("warning", `${glyphs.running} electing`)}${queued}`;
|
|
}
|
|
if (!state.pollingActive && state.busRole !== "follower") {
|
|
return `${theme.fg("accent", "telegram")} ${theme.fg("muted", `${glyphs.shadowed} disconnected`)}${queued}${hint("/telegram-connect")}`;
|
|
}
|
|
const details = buildTelegramStatusBarDetails(theme, glyphs, state);
|
|
if (state.processing) {
|
|
const processingStatus = state.queuedStatus
|
|
? "active"
|
|
: (state.processingStatus ?? "processing");
|
|
const processingToken =
|
|
processingStatus === "active" ? "warning" : "accent";
|
|
return `${label} ${theme.fg(processingToken, `${glyphs.running} ${processingStatus}`)}${queued}${details}`;
|
|
}
|
|
const role = state.busRole ?? "connected";
|
|
return `${label} ${theme.fg("success", `${glyphs.enabled} ${role}`)}${queued}${details}`;
|
|
}
|
|
|
|
/** Up to two queued turns and a remainder count, only while the bridge is up. */
|
|
function buildTelegramStatusBarQueueLine(
|
|
theme: TelegramStatusBarTheme,
|
|
state: TelegramStatusBarState,
|
|
): string | undefined {
|
|
const operational =
|
|
state.hasBotToken &&
|
|
state.paired &&
|
|
!state.error &&
|
|
state.busLifecyclePhase !== "electing" &&
|
|
(state.pollingActive || state.busRole === "follower");
|
|
const preview = (state.queuedPreview ?? [])
|
|
.map(sanitizeTelegramStatusText)
|
|
.filter((summary) => summary.length > 0);
|
|
if (!operational || preview.length === 0) return undefined;
|
|
const glyphs = resolveTelegramStatusBarGlyphs(theme);
|
|
const shown = preview.slice(0, 2).map((summary) => `"${summary}"`);
|
|
const more =
|
|
preview.length > 2 ? `${glyphs.dot}+${preview.length - 2} more` : "";
|
|
return theme.fg("muted", ` next: ${shown.join(glyphs.dot)}${more}`);
|
|
}
|
|
|
|
/** The main status line, plus a queue preview line when turns are waiting. */
|
|
export function buildTelegramStatusBarLines(
|
|
theme: TelegramStatusBarTheme,
|
|
state: TelegramStatusBarState,
|
|
): string[] {
|
|
const queueLine = buildTelegramStatusBarQueueLine(theme, state);
|
|
return [
|
|
buildTelegramStatusBarText(theme, state),
|
|
...(queueLine ? [queueLine] : []),
|
|
];
|
|
}
|
|
|
|
/** Host spinner text while a turn that arrived from Telegram is running. */
|
|
export function formatTelegramWorkingMessage(summary: string): string {
|
|
const text = sanitizeTelegramStatusText(summary);
|
|
return text ? `Answering Telegram · "${text}"` : "Answering Telegram";
|
|
}
|
|
|
|
function formatTelegramBridgeBotStatus(
|
|
state: Pick<TelegramBridgeStatusLineState, "hasBotToken" | "botUsername">,
|
|
): string {
|
|
if (state.botUsername) return `@${state.botUsername}`;
|
|
return state.hasBotToken ? "unknown" : "not configured";
|
|
}
|
|
|
|
function formatTelegramStatusTarget(
|
|
target: { chatId: number; threadId?: number } | undefined,
|
|
): string {
|
|
if (!target) return "";
|
|
return target.threadId === undefined
|
|
? ` target ${target.chatId}`
|
|
: ` target ${target.chatId}:${target.threadId}`;
|
|
}
|
|
|
|
function formatTelegramThreadStatusLabel(input: {
|
|
threadName?: string;
|
|
slot?: string;
|
|
}): string {
|
|
const threadName = input.threadName?.trim();
|
|
if (threadName) return threadName;
|
|
return input.slot ? `[${input.slot}]` : "";
|
|
}
|
|
|
|
function buildTelegramBusFollowerLines(
|
|
state: Pick<TelegramBridgeStatusLineState, "busFollowers" | "busNowMs">,
|
|
): string[] {
|
|
const followers = state.busFollowers ?? [];
|
|
if (followers.length === 0) return [];
|
|
const nowMs = state.busNowMs ?? Date.now();
|
|
return [
|
|
"",
|
|
"bus:",
|
|
`- followers: ${followers.length}`,
|
|
...followers.map((follower) => {
|
|
const ageSeconds = Math.max(
|
|
0,
|
|
Math.round((nowMs - follower.lastHeartbeatMs) / 1000),
|
|
);
|
|
const label = formatTelegramThreadStatusLabel(follower);
|
|
const labelSuffix = label ? ` ${label}` : "";
|
|
const statusLabel = follower.status ? ` (${follower.status})` : "";
|
|
const cwd = follower.cwd ? ` ${follower.cwd}` : "";
|
|
const target = formatTelegramStatusTarget(follower.target);
|
|
return `- ${follower.instanceId}:${labelSuffix} heartbeat ${ageSeconds}s ago${statusLabel}${target}${cwd}`;
|
|
}),
|
|
];
|
|
}
|
|
|
|
function buildTelegramLocalBusLines(
|
|
state: Pick<TelegramBridgeStatusLineState, "localBus">,
|
|
options: { verbose?: boolean } = {},
|
|
): string[] {
|
|
const localBus = state.localBus;
|
|
if (!localBus) return [];
|
|
const target = formatTelegramStatusTarget(localBus.followerTarget);
|
|
const label = formatTelegramThreadStatusLabel({
|
|
slot: localBus.followerSlot,
|
|
threadName: localBus.followerThreadName,
|
|
});
|
|
const followerLine = `- follower registered: ${localBus.followerRegistered ? "yes" : "no"}${label ? ` ${label}` : ""}${target}`;
|
|
const lines = ["", "local bus:", followerLine];
|
|
if (options.verbose) {
|
|
if (localBus.leaderSocketPath) {
|
|
const transport = localBus.leaderTransport
|
|
? ` [${localBus.leaderTransport}]`
|
|
: "";
|
|
lines.push(`- leader endpoint${transport}: ${localBus.leaderSocketPath}`);
|
|
}
|
|
if (localBus.followerSocketPath) {
|
|
const transport = localBus.followerTransport
|
|
? ` [${localBus.followerTransport}]`
|
|
: "";
|
|
lines.push(
|
|
`- follower endpoint${transport}: ${localBus.followerSocketPath}`,
|
|
);
|
|
}
|
|
}
|
|
return lines;
|
|
}
|
|
|
|
function buildTelegramSyncSliceLines(
|
|
state: Pick<TelegramBridgeStatusLineState, "syncState">,
|
|
): string[] {
|
|
const syncState = state.syncState;
|
|
if (!syncState || Object.keys(syncState).length === 0) return [];
|
|
return [
|
|
"sync:",
|
|
...Object.entries(syncState).map(([slice, value]) => {
|
|
const status = value?.status ?? "unknown";
|
|
const action = value?.lastReconcileAction
|
|
? ` reconcile=${value.lastReconcileAction}`
|
|
: "";
|
|
const reason = value?.reason ? ` reason=${value.reason}` : "";
|
|
return `- ${slice}: ${status}${action}${reason}`;
|
|
}),
|
|
];
|
|
}
|
|
|
|
function buildTelegramThreadReconciliationLines(
|
|
state: Pick<TelegramBridgeStatusLineState, "threadReconciliation">,
|
|
): string[] {
|
|
const reconciliation = state.threadReconciliation;
|
|
if (!reconciliation) return [];
|
|
const epoch =
|
|
reconciliation.leaderEpoch !== undefined
|
|
? ` epoch=${reconciliation.leaderEpoch}`
|
|
: "";
|
|
return [
|
|
"reconciliation:",
|
|
`- phase: ${reconciliation.phase} event=${reconciliation.event}${epoch}`,
|
|
`- counts: pending=${reconciliation.pendingProvisionCount}, sync=${reconciliation.syncActionCount}, cleanup=${reconciliation.cleanupActionCount}`,
|
|
];
|
|
}
|
|
|
|
function buildTelegramTopicTargetDiagnosticLines(
|
|
state: Pick<
|
|
TelegramBridgeStatusLineState,
|
|
"topicTargets" | "threadReservations" | "topicSyncObservations"
|
|
>,
|
|
): string[] {
|
|
const activeTargets = (state.topicTargets ?? []).filter(
|
|
(record) =>
|
|
!!record.instanceId &&
|
|
(record.status === "active" || record.status === "starting"),
|
|
);
|
|
const reservations = state.threadReservations ?? [];
|
|
const observations = state.topicSyncObservations ?? [];
|
|
if (
|
|
activeTargets.length === 0 &&
|
|
reservations.length === 0 &&
|
|
observations.length === 0
|
|
)
|
|
return [];
|
|
const byInstance = new Map<string, TelegramBridgeStatusTopicTarget[]>();
|
|
for (const record of activeTargets) {
|
|
const key = record.instanceId;
|
|
if (!key) continue;
|
|
const records = byInstance.get(key) ?? [];
|
|
records.push(record);
|
|
byInstance.set(key, records);
|
|
}
|
|
const duplicateLines = Array.from(byInstance.entries())
|
|
.filter(([, records]) => records.length > 1)
|
|
.map(([instanceId, records]) => {
|
|
const targets = records
|
|
.map((record) => {
|
|
const label = formatTelegramThreadStatusLabel(record);
|
|
return `${label}${formatTelegramStatusTarget(record.target) || " unknown"}`.trim();
|
|
})
|
|
.join(", ");
|
|
return `- duplicate ${instanceId}: ${records.length} active threads ${targets}`;
|
|
});
|
|
const twinLines = activeTargets.map((record) => {
|
|
const label = formatTelegramThreadStatusLabel(record);
|
|
const target = formatTelegramStatusTarget(record.target) || " unknown";
|
|
const sync = record.syncStatus ? ` sync=${record.syncStatus}` : "";
|
|
const observed = record.lastSyncObservedAtMs
|
|
? ` observed=${new Date(record.lastSyncObservedAtMs).toISOString()}`
|
|
: "";
|
|
const probe = record.lastSyncProbeAtMs
|
|
? ` probed=${new Date(record.lastSyncProbeAtMs).toISOString()}`
|
|
: "";
|
|
const error = record.lastSyncError
|
|
? ` syncError=${record.lastSyncError}`
|
|
: "";
|
|
const action = record.lastReconcileAction
|
|
? ` reconcile=${record.lastReconcileAction}`
|
|
: "";
|
|
return `- ${label}${target}${sync}${observed}${probe}${error}${action}`.trim();
|
|
});
|
|
const reservationLines = reservations.map((reservation) => {
|
|
const slot = reservation.slot ? `[${reservation.slot}]` : "";
|
|
const target = formatTelegramStatusTarget(reservation.target) || " unknown";
|
|
const reason = reservation.reason ? ` reason=${reservation.reason}` : "";
|
|
const instance = reservation.instanceId
|
|
? ` instance=${reservation.instanceId}`
|
|
: "";
|
|
const action = reservation.lastReconcileAction
|
|
? ` reconcile=${reservation.lastReconcileAction}`
|
|
: "";
|
|
return `- reservation ${slot}${target}${reason}${instance}${action}`.trim();
|
|
});
|
|
const observationLines = observations.map((observation) => {
|
|
const slot = observation.slot ? `[${observation.slot}]` : "";
|
|
const target = formatTelegramStatusTarget(observation.target) || " unknown";
|
|
const observed = ` observed=${new Date(observation.observedAtMs).toISOString()}`;
|
|
const instance = observation.instanceId
|
|
? ` instance=${observation.instanceId}`
|
|
: "";
|
|
const error = observation.lastSyncError
|
|
? ` syncError=${observation.lastSyncError}`
|
|
: "";
|
|
const action = observation.lastReconcileAction
|
|
? ` reconcile=${observation.lastReconcileAction}`
|
|
: "";
|
|
return `- sync ${slot}${target} sync=${observation.syncStatus}${observed}${instance}${error}${action}`.trim();
|
|
});
|
|
return [
|
|
"topics:",
|
|
`- active bindings: instances=${byInstance.size}, targets=${activeTargets.length}`,
|
|
...duplicateLines,
|
|
...twinLines,
|
|
...reservationLines,
|
|
...observationLines,
|
|
];
|
|
}
|
|
|
|
function buildTelegramBridgeCompactThreadLines(
|
|
state: Pick<
|
|
TelegramBridgeStatusLineState,
|
|
| "busFollowers"
|
|
| "topicTargets"
|
|
| "threadReservations"
|
|
| "topicSyncObservations"
|
|
>,
|
|
): string[] {
|
|
const activeTargets = (state.topicTargets ?? []).filter(
|
|
(record) =>
|
|
!!record.instanceId &&
|
|
(record.status === "active" || record.status === "starting"),
|
|
);
|
|
const activeLabels = activeTargets
|
|
.map(formatTelegramThreadStatusLabel)
|
|
.filter((label) => label.length > 0);
|
|
const followers = state.busFollowers ?? [];
|
|
const reservations = state.threadReservations ?? [];
|
|
const observations = state.topicSyncObservations ?? [];
|
|
if (
|
|
activeLabels.length === 0 &&
|
|
followers.length === 0 &&
|
|
reservations.length === 0 &&
|
|
observations.length === 0
|
|
) {
|
|
return [];
|
|
}
|
|
const lines = ["threads:"];
|
|
if (activeLabels.length > 0)
|
|
lines.push(`- active: ${activeLabels.join(", ")}`);
|
|
if (followers.length > 0) lines.push(`- followers: ${followers.length}`);
|
|
if (reservations.length > 0) lines.push(`- reserved: ${reservations.length}`);
|
|
const syncIssueCount = observations.filter(
|
|
(observation) => observation.syncStatus !== "open",
|
|
).length;
|
|
if (syncIssueCount > 0) lines.push(`- sync issues: ${syncIssueCount}`);
|
|
return lines;
|
|
}
|
|
|
|
function buildTelegramBridgeCompactStatusLines(
|
|
state: TelegramBridgeStatusLineState,
|
|
): string[] {
|
|
const controlQueueCount = state.queuedItems.filter(
|
|
(item) => item.queueLane === "control",
|
|
).length;
|
|
const priorityQueueCount = state.queuedItems.filter(
|
|
(item) => item.queueLane === "priority",
|
|
).length;
|
|
const defaultQueueCount = state.queuedItems.filter(
|
|
(item) => item.queueLane === "default",
|
|
).length;
|
|
const queueLine = `- queued turns: ${state.queuedItems.length}${
|
|
state.queuedItems.length > 0
|
|
? ` (control=${controlQueueCount}, priority=${priorityQueueCount}, default=${defaultQueueCount})`
|
|
: ""
|
|
}`;
|
|
const executionState = state.pendingDispatch
|
|
? "pending dispatch"
|
|
: state.activeSourceMessageIds?.length
|
|
? "active"
|
|
: "idle";
|
|
const profileSuffix = state.activeProfileName
|
|
? `.${state.activeProfileName.replace(/[^a-zA-Z0-9._-]+/g, "_")}`
|
|
: "";
|
|
const profileSlug = profileSuffix.slice(1);
|
|
const diagnosticsPaths = state.diagnosticPaths ?? {
|
|
state: `~/.pi/agent/tmp/telegram/state${profileSuffix}.json`,
|
|
logs: `~/.pi/agent/tmp/telegram/logs${profileSlug ? `.${profileSlug}` : ""}.jsonl`,
|
|
};
|
|
return [
|
|
"connection:",
|
|
`- bot: ${formatTelegramBridgeBotStatus(state)}`,
|
|
...(state.activeProfileName ? [`- profile: ${state.activeProfileName}`] : []),
|
|
`- user: ${state.allowedUserId ?? "not paired"}`,
|
|
...buildTelegramTrafficLines(state),
|
|
...(state.botThreadMode ? [`- thread mode: ${state.botThreadMode}`] : []),
|
|
...(state.busRole ? [`- role: ${state.busRole}`] : []),
|
|
...(state.busLifecyclePhase
|
|
? [`- lifecycle: ${state.busLifecyclePhase}`]
|
|
: []),
|
|
...(state.instanceThreadName || state.instanceSlot
|
|
? [`- instance: ${state.instanceThreadName ?? state.instanceSlot}`]
|
|
: []),
|
|
...(state.lockState ? [`- owner: ${state.lockState}`] : []),
|
|
"",
|
|
"health:",
|
|
`- polling: ${state.pollingActive ? "running" : "stopped"}`,
|
|
`- state: ${executionState}`,
|
|
queueLine,
|
|
...(state.activeToolExecutions > 0
|
|
? [`- active tools: ${state.activeToolExecutions}`]
|
|
: []),
|
|
...(state.pendingModelSwitch ? ["- pending model switch: yes"] : []),
|
|
...buildTelegramBridgeCompactThreadLines(state),
|
|
...buildTelegramBusFollowerLines(state),
|
|
...buildTelegramLocalBusLines(state),
|
|
...buildTelegramThreadReconciliationLines(state),
|
|
"",
|
|
"diagnostics:",
|
|
`- state: ${diagnosticsPaths.state}`,
|
|
`- logs: ${diagnosticsPaths.logs}`,
|
|
"- full dump: /telegram-status --debug",
|
|
];
|
|
}
|
|
|
|
export function buildTelegramBridgeStatusLines(
|
|
state: TelegramBridgeStatusLineState,
|
|
options: TelegramBridgeStatusLineOptions = {},
|
|
): string[] {
|
|
if (options.verbose) return buildTelegramBridgeDiagnosticStatusLines(state);
|
|
return buildTelegramBridgeCompactStatusLines(state);
|
|
}
|
|
|
|
export function buildTelegramBridgeDiagnosticStatusLines(
|
|
state: TelegramBridgeStatusLineState,
|
|
): string[] {
|
|
const controlQueueCount = state.queuedItems.filter(
|
|
(item) => item.queueLane === "control",
|
|
).length;
|
|
const priorityQueueCount = state.queuedItems.filter(
|
|
(item) => item.queueLane === "priority",
|
|
).length;
|
|
const defaultQueueCount = state.queuedItems.filter(
|
|
(item) => item.queueLane === "default",
|
|
).length;
|
|
return [
|
|
"connection:",
|
|
`- bot: ${formatTelegramBridgeBotStatus(state)}`,
|
|
...(state.activeProfileName ? [`- profile: ${state.activeProfileName}`] : []),
|
|
`- allowed user: ${state.allowedUserId ?? "not paired"}`,
|
|
...buildTelegramTrafficLines(state),
|
|
...(state.botThreadMode
|
|
? [
|
|
`- thread mode: ${state.botThreadMode}${state.botThreadModeAction ? ` reconcile=${state.botThreadModeAction}` : ""}`,
|
|
]
|
|
: []),
|
|
...(state.busRole ? [`- bus role: ${state.busRole}`] : []),
|
|
...(state.busLifecyclePhase
|
|
? [`- bus lifecycle: ${state.busLifecyclePhase}`]
|
|
: []),
|
|
...(state.instanceThreadName || state.instanceSlot
|
|
? [`- instance: ${state.instanceThreadName ?? state.instanceSlot}`]
|
|
: []),
|
|
...(state.lockState ? [`- owner: ${state.lockState}`] : []),
|
|
"",
|
|
"polling:",
|
|
`- state: ${state.pollingActive ? "running" : "stopped"}`,
|
|
`- last update id: ${state.lastUpdateId ?? "none"}`,
|
|
"",
|
|
"execution:",
|
|
`- active turn: ${state.activeSourceMessageIds?.join(",") || "no"}`,
|
|
`- pending dispatch: ${state.pendingDispatch ? "yes" : "no"}`,
|
|
`- compaction: ${state.compactionInProgress ? "running" : "idle"}`,
|
|
`- active tools: ${state.activeToolExecutions}`,
|
|
`- pending model switch: ${state.pendingModelSwitch ? "yes" : "no"}`,
|
|
"",
|
|
"queue:",
|
|
`- queued turns: ${state.queuedItems.length}`,
|
|
`- lanes: control=${controlQueueCount}, priority=${priorityQueueCount}, default=${defaultQueueCount}`,
|
|
...buildTelegramBusFollowerLines(state),
|
|
...buildTelegramLocalBusLines(state, { verbose: true }),
|
|
...buildTelegramTopicTargetDiagnosticLines(state),
|
|
...buildTelegramThreadReconciliationLines(state),
|
|
...buildTelegramSyncSliceLines(state),
|
|
"",
|
|
...buildTelegramRuntimeEventLines(state.recentRuntimeEvents),
|
|
];
|
|
}
|
|
|
|
function escapeHtml(text: string): string {
|
|
return text
|
|
.replace(/&/g, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">");
|
|
}
|
|
|
|
function formatTokens(count: number): string {
|
|
if (count < 1000) return count.toString();
|
|
if (count < 10000) return `${(count / 1000).toFixed(1)}k`;
|
|
if (count < 1000000) return `${Math.round(count / 1000)}k`;
|
|
if (count < 10000000) return `${(count / 1000000).toFixed(1)}M`;
|
|
return `${Math.round(count / 1000000)}M`;
|
|
}
|
|
|
|
function collectUsageStats(ctx: TelegramStatusContext): TelegramUsageStats {
|
|
const stats: TelegramUsageStats = {
|
|
totalInput: 0,
|
|
totalOutput: 0,
|
|
totalCacheRead: 0,
|
|
totalCacheWrite: 0,
|
|
totalCost: 0,
|
|
};
|
|
for (const entry of ctx.sessionManager.getEntries()) {
|
|
const usage = entry.message?.usage;
|
|
if (
|
|
entry.type !== "message" ||
|
|
entry.message?.role !== "assistant" ||
|
|
!usage
|
|
) {
|
|
continue;
|
|
}
|
|
stats.totalInput += usage.input;
|
|
stats.totalOutput += usage.output;
|
|
stats.totalCacheRead += usage.cacheRead;
|
|
stats.totalCacheWrite += usage.cacheWrite;
|
|
stats.totalCost += usage.cost.total;
|
|
}
|
|
return stats;
|
|
}
|
|
|
|
function formatStatusRowLabel(label: string): string {
|
|
if (!label) return label;
|
|
return `${label[0]?.toUpperCase() ?? ""}${label.slice(1)}`;
|
|
}
|
|
|
|
function buildStatusRow(label: string, value: string): string {
|
|
return `<b>${escapeHtml(formatStatusRowLabel(label))}:</b> <code>${escapeHtml(value)}</code>`;
|
|
}
|
|
|
|
function buildUsageSummary(stats: TelegramUsageStats): string | undefined {
|
|
const tokenParts: string[] = [];
|
|
if (stats.totalInput) tokenParts.push(`↑${formatTokens(stats.totalInput)}`);
|
|
if (stats.totalOutput) tokenParts.push(`↓${formatTokens(stats.totalOutput)}`);
|
|
if (stats.totalCacheRead)
|
|
tokenParts.push(`R${formatTokens(stats.totalCacheRead)}`);
|
|
if (stats.totalCacheWrite)
|
|
tokenParts.push(`W${formatTokens(stats.totalCacheWrite)}`);
|
|
return tokenParts.length > 0 ? tokenParts.join(" ") : undefined;
|
|
}
|
|
|
|
function buildCostSummary(
|
|
stats: TelegramUsageStats,
|
|
usesSubscription: boolean,
|
|
): string | undefined {
|
|
if (!stats.totalCost && !usesSubscription) return undefined;
|
|
return `$${stats.totalCost.toFixed(3)}${usesSubscription ? " (sub)" : ""}`;
|
|
}
|
|
|
|
function buildContextSummary(
|
|
ctx: TelegramStatusContext,
|
|
activeModel: TelegramStatusActiveModel | undefined,
|
|
): string {
|
|
const usage = ctx.getContextUsage();
|
|
if (!usage) return "unknown";
|
|
const contextWindow = usage.contextWindow ?? activeModel?.contextWindow ?? 0;
|
|
const percent = usage.percent !== null ? `${usage.percent.toFixed(1)}%` : "?";
|
|
return `${percent}/${formatTokens(contextWindow)}`;
|
|
}
|
|
|
|
function buildStatusSummary(ctx: TelegramStatusContext): string {
|
|
if (ctx.hasPendingMessages?.()) return "pending";
|
|
if (ctx.isIdle?.() === false) return "active";
|
|
if (ctx.isIdle?.() === true) return "idle";
|
|
return "unknown";
|
|
}
|
|
|
|
function buildTelegramStatusRoleSuffix(
|
|
state: TelegramBridgeStatusLineState | undefined,
|
|
): string {
|
|
if (state?.botThreadMode !== "enabled" || !state.busRole) return "";
|
|
return ` @${state.busRole}`;
|
|
}
|
|
|
|
export function buildStatusHtml(
|
|
ctx: TelegramStatusContext,
|
|
activeModel: TelegramStatusActiveModel | undefined,
|
|
bridgeStatus?: TelegramBridgeStatusLineState,
|
|
): string {
|
|
const stats = collectUsageStats(ctx);
|
|
const usesSubscription = activeModel
|
|
? ctx.modelRegistry.isUsingOAuth(activeModel)
|
|
: false;
|
|
const lines: string[] = [
|
|
buildStatusRow(
|
|
"Status",
|
|
`${buildStatusSummary(ctx)}${buildTelegramStatusRoleSuffix(bridgeStatus)}`,
|
|
),
|
|
];
|
|
const usageSummary = buildUsageSummary(stats);
|
|
const costSummary = buildCostSummary(stats, usesSubscription);
|
|
if (usageSummary) {
|
|
lines.push(buildStatusRow("Usage", usageSummary));
|
|
}
|
|
if (costSummary) {
|
|
lines.push(buildStatusRow("Cost", costSummary));
|
|
}
|
|
lines.push(buildStatusRow("Context", buildContextSummary(ctx, activeModel)));
|
|
for (const row of getTelegramStatusLineProviderResults({ activeModel })) {
|
|
lines.push(buildStatusRow(row.label, row.value));
|
|
}
|
|
return lines.join("\n");
|
|
}
|