/** * Status synchronization tracker for pi-c2c. * * Tracks the local session's runtime state from pi SDK lifecycle hooks and * publishes structured status envelopes to an injected sink. Updates are * throttled and coalesced so rapid event flapping does not churn the sink. */ export type KnownStatusState = "idle" | "processing" | "tool" | "input"; /** Accept any string so peers with newer state vocabularies still render cleanly. */ export type StatusState = KnownStatusState | (string & {}); export interface StatusEnvelope { event: "status"; from: string; state: StatusState; since: string; ttl_ms: number; } export interface StatusRecord { state: StatusState; since: number; ttlMs: number; } export interface StatusTrackerOptions { alias: string; minIntervalMs?: number; inputTtlMs?: number; toolTtlMs?: number; processingTtlMs?: number; idleTtlMs?: number; now?: () => number; } export type StatusSink = (envelope: StatusEnvelope) => Promise | void; const DEFAULT_MIN_INTERVAL_MS = 2_000; const DEFAULT_INPUT_TTL_MS = 5_000; const DEFAULT_TOOL_TTL_MS = 30_000; const DEFAULT_PROCESSING_TTL_MS = 60_000; const DEFAULT_IDLE_TTL_MS = 60_000; function ttlFor(state: StatusState, opts: Required): number { switch (state) { case "input": return opts.inputTtlMs; case "tool": return opts.toolTtlMs; case "processing": return opts.processingTtlMs; case "idle": return opts.idleTtlMs; default: return opts.idleTtlMs; } } export interface StatusTracker { transition(state: StatusState): void; getStatus(): StatusRecord; setSink(fn: StatusSink | undefined): void; dispose(): void; } /** * Create a status tracker. * * The tracker keeps a current state and schedules broadcasts. It only emits * when the effective state changes, and never more often than * `minIntervalMs`. If transitions arrive while a broadcast is delayed, the * pending state is updated and a single broadcast is sent at the next slot. */ export function createStatusTracker(opts: StatusTrackerOptions): StatusTracker { const alias = opts.alias; const minIntervalMs = opts.minIntervalMs ?? DEFAULT_MIN_INTERVAL_MS; const resolved: Required = { alias, minIntervalMs, inputTtlMs: opts.inputTtlMs ?? DEFAULT_INPUT_TTL_MS, toolTtlMs: opts.toolTtlMs ?? DEFAULT_TOOL_TTL_MS, processingTtlMs: opts.processingTtlMs ?? DEFAULT_PROCESSING_TTL_MS, idleTtlMs: opts.idleTtlMs ?? DEFAULT_IDLE_TTL_MS, now: opts.now ?? (() => Date.now()), }; let current: StatusRecord = { state: "idle", since: resolved.now(), ttlMs: resolved.idleTtlMs, }; let previousNonInputState: StatusState = "idle"; let sink: StatusSink | undefined; let pendingState: StatusState | undefined; let broadcastTimer: ReturnType | null = null; let inputRevertTimer: ReturnType | null = null; let lastBroadcastAt = 0; let lastPublishedState: StatusState | undefined; let disposed = false; function buildEnvelope(state: StatusState, since: number): StatusEnvelope { return { event: "status", from: alias, state, since: new Date(since).toISOString(), ttl_ms: ttlFor(state, resolved), }; } function clearBroadcastTimer(): void { if (broadcastTimer) { clearTimeout(broadcastTimer); broadcastTimer = null; } } function clearInputTimer(): void { if (inputRevertTimer) { clearTimeout(inputRevertTimer); inputRevertTimer = null; } } function clearTimers(): void { clearBroadcastTimer(); clearInputTimer(); pendingState = undefined; } function doBroadcast(state: StatusState, since: number): void { if (disposed) return; lastBroadcastAt = resolved.now(); lastPublishedState = state; pendingState = undefined; broadcastTimer = null; if (!sink) return; try { const result = sink(buildEnvelope(state, since)); if (result && typeof result.catch === "function") { result.catch(() => { // Broadcast failures are best-effort; the next transition will retry. }); } } catch { // Synchronous failures are also best-effort. } } function scheduleBroadcast(state: StatusState, since: number): void { if (disposed) return; pendingState = state; const elapsed = resolved.now() - lastBroadcastAt; const delay = Math.max(0, minIntervalMs - elapsed); if (broadcastTimer) return; // already waiting for a slot; pendingState was updated above broadcastTimer = setTimeout(() => { if (disposed) return; doBroadcast(pendingState ?? current.state, current.since); }, delay); } function maybeBroadcast(state: StatusState, since: number): void { if (disposed) return; // If the state we last published (or have queued) is already this, skip. if (state === pendingState && broadcastTimer) return; if (state === lastPublishedState && !broadcastTimer) return; scheduleBroadcast(state, since); } return { transition(state: StatusState): void { if (disposed) return; const now = resolved.now(); const oldState = current.state; current = { state, since: now, ttlMs: ttlFor(state, resolved) }; if (state !== "input") { previousNonInputState = state; } // Input is transient: revert to the previous non-input state after its // TTL unless another transition arrives first. if (state === "input") { clearInputTimer(); inputRevertTimer = setTimeout(() => { if (disposed) return; if (current.state === "input") { current = { state: previousNonInputState, since: resolved.now(), ttlMs: ttlFor(previousNonInputState, resolved), }; maybeBroadcast(previousNonInputState, current.since); } }, resolved.inputTtlMs); } else { clearInputTimer(); } // If this is a real state change, broadcast it (subject to throttle). if (state !== oldState || lastBroadcastAt === 0) { maybeBroadcast(state, now); } }, getStatus(): StatusRecord { return { ...current }; }, setSink(fn: StatusSink | undefined): void { sink = fn; }, dispose(): void { disposed = true; clearTimers(); sink = undefined; }, }; } /** * Render a status record as the XML envelope used by c2c. */ export function formatStatusEnvelope(envelope: StatusEnvelope): string { return ( `` ); } function escapeXml(value: string): string { return value .replace(/&/g, "&") .replace(/"/g, """) .replace(/'/g, "'") .replace(//g, ">"); } /** * Parse a status envelope from raw c2c content. Tolerant: returns `null` for * any envelope that is not a well-formed status update. * * Status envelopes are often delivered as the body of a normal c2c message, * and peer content is sanitized so that `$/, ); if (!match) return null; const from = match[1]; const state = match[2]; const since = match[3]; const ttl = Number(match[4]); if (!isKnownStatusState(state)) return null; if (!/^(?!\.)[A-Za-z0-9._-]{1,64}$/.test(from)) return null; if (expectedFrom !== undefined && from !== expectedFrom) return null; if (!Number.isFinite(Date.parse(since))) return null; if (!Number.isFinite(ttl) || ttl < 0 || ttl > 300_000) return null; return { event: "status", from, state, since, ttl_ms: ttl }; } function isKnownStatusState(value: string): value is KnownStatusState { return value === "idle" || value === "processing" || value === "tool" || value === "input"; }