import { createHash } from "node:crypto"; import { hostname } from "node:os"; import { basename } from "node:path"; import type { AgentTickClient } from "./agentTickClient"; import { createPrivateStatusUpdateInput, createPrivateToolActivityInput } from "./privateStatus"; import type { AgentTickSessionContext } from "./session"; import type { ContextUsage, CreateStatusUpdateInput, CreateToolActivityInput, StatusMessageMirrorConfig, StatusState } from "./types"; function defaultThreadId(): string { const raw = `${hostname()}:${process.cwd()}`; if (raw.length <= 200) return raw; const hash = createHash("sha256").update(raw).digest("hex").slice(0, 12); const host = hostname().slice(0, 80); const project = basename(process.cwd()).slice(0, 80); return `${host}:${project}:${hash}`; } export class StatusReporter { private lastSentAt = 0; private lastTurnHeartbeatAt = 0; private pending: ReturnType | undefined; private heartbeat: ReturnType | undefined; private blockedSinceLastWork = false; private workingPauseCount = 0; private mirrorFailureWarned = false; constructor( private readonly client: AgentTickClient, private readonly debounceMs = 5000, private readonly turnHeartbeatMs = 60_000, private readonly sessionContext?: AgentTickSessionContext, private readonly onMirrorFailure?: (error: unknown) => void, ) {} /** * Warn at most once per session that remote Activity mirroring failed, so a * schema-drift / write-path outage is visible without spamming logs or * disturbing the agent turn. Local Pi status continues regardless. */ private reportMirrorFailure(error: unknown): void { if (this.mirrorFailureWarned) return; this.mirrorFailureWarned = true; this.onMirrorFailure?.(error); } send(state: StatusState, message: string, extra: Partial = {}): void { if (!this.shouldSend(state)) return; this.sendPayload(state, message, extra); } async sendAsync(state: StatusState, message: string, extra: Partial = {}): Promise { if (!this.shouldSend(state)) return; const payload = this.buildPayload(state, message, extra); clearTimeout(this.pending); this.pending = undefined; await this.flush(payload); } async sendMessageStatus(input: { role: "assistant" | "user" | "system"; body: string; preview: string; config: StatusMessageMirrorConfig; contextUsage?: ContextUsage }): Promise { const genericMessage = input.role === "user" ? "User replied" : input.role === "assistant" ? "Assistant replied" : "Message added"; const metadata = { event: "message_end", role: input.role }; const base = this.buildPayload(input.role === "user" ? "working" : "waiting", input.config.contentMode === "plain" ? input.preview : genericMessage, { metadata, ...(input.config.includeContextUsage && input.contextUsage ? { contextUsage: input.contextUsage } : {}), }); clearTimeout(this.pending); this.pending = undefined; if (input.config.contentMode !== "private") { await this.flush(base); return; } try { const prepared = await this.client.preparePrivateStatusUpdate(); const privatePayload = createPrivateStatusUpdateInput(base, { schemaVersion: 1, kind: "status_update", message: input.preview, body: input.body, role: input.role, presentation: { collapsedByDefault: input.config.collapsedByDefault, contentFormat: "markdown", }, }, prepared); this.lastSentAt = Date.now(); await this.client.createStatusUpdate(privatePayload); } catch (error) { this.reportMirrorFailure(error); await this.flush({ ...base, message: genericMessage, contentMode: "plain", encryptedPayload: undefined, privateRecipientVersion: undefined }); } } async sendToolActivity(input: CreateToolActivityInput & { detail?: Record }): Promise { const { detail, ...baseInput } = input; const base = this.buildToolActivityPayload(baseInput); clearTimeout(this.pending); this.pending = undefined; if (!detail) { await this.flushToolActivity(base); return; } try { const prepared = await this.client.preparePrivateStatusUpdate(); const privatePayload = createPrivateToolActivityInput(base, { schemaVersion: 1, kind: "tool_activity", ...detail, }, prepared); this.lastSentAt = Date.now(); await this.client.createToolActivity(privatePayload); } catch (error) { this.reportMirrorFailure(error); await this.flushToolActivity({ ...base, contentMode: "plain", encryptedPayload: undefined, privateRecipientVersion: undefined }); } } sendTurnCompleted(message = "Last turn completed; Pi is still working"): void { if (this.workingPauseCount > 0) return; if (this.blockedSinceLastWork) return; const now = Date.now(); if (now - this.lastTurnHeartbeatAt < this.turnHeartbeatMs) return; this.lastTurnHeartbeatAt = now; this.sendPayload("working", message, { metadata: { event: "turn_end" }, }); } startHeartbeat(message = "Still working", intervalMs = 60_000): void { this.stopHeartbeat(); if (!Number.isFinite(intervalMs) || intervalMs <= 0) return; this.heartbeat = setInterval(() => { if (this.workingPauseCount > 0) return; if (this.blockedSinceLastWork) return; this.sendPayload("working", message, { metadata: { event: "working_heartbeat" }, }); }, intervalMs); this.heartbeat.unref?.(); } stopHeartbeat(): void { clearInterval(this.heartbeat); this.heartbeat = undefined; } pauseWorkingStatus(): () => void { this.workingPauseCount += 1; clearTimeout(this.pending); this.pending = undefined; let resumed = false; return () => { if (resumed) return; resumed = true; this.workingPauseCount = Math.max(0, this.workingPauseCount - 1); }; } private shouldSend(state: StatusState): boolean { if (state === "working" && this.workingPauseCount > 0) return false; if (state === "working") this.blockedSinceLastWork = false; if (state === "blocked") this.blockedSinceLastWork = true; if ((state === "done" || state === "waiting") && this.blockedSinceLastWork) return false; return true; } private buildPayload(state: StatusState, message: string, extra: Partial = {}): CreateStatusUpdateInput { return { state, message, threadId: defaultThreadId(), host: hostname(), workingDirectory: process.cwd(), clientName: basename(process.cwd()), ...(this.sessionContext?.current() ?? {}), ...extra, }; } private buildToolActivityPayload(input: CreateToolActivityInput): CreateToolActivityInput { const session = this.sessionContext?.current(); return { threadId: defaultThreadId(), ...(session?.sessionId ? { sessionId: session.sessionId } : {}), ...input, metadata: { host: hostname(), workingDirectory: process.cwd(), clientName: basename(process.cwd()), ...(input.metadata ?? {}), }, }; } private sendPayload(state: StatusState, message: string, extra: Partial = {}): void { const payload = this.buildPayload(state, message, extra); const elapsed = Date.now() - this.lastSentAt; if (elapsed >= this.debounceMs || state === "blocked" || state === "done" || state === "waiting") { clearTimeout(this.pending); this.pending = undefined; void this.flush(payload); return; } clearTimeout(this.pending); this.pending = setTimeout(() => void this.flush(payload), this.debounceMs - elapsed); } private async flush(payload: CreateStatusUpdateInput): Promise { this.lastSentAt = Date.now(); try { await this.client.createStatusUpdate(payload); } catch (error) { this.reportMirrorFailure(error); // Status is best-effort; never disturb the agent turn. } } private async flushToolActivity(payload: CreateToolActivityInput): Promise { this.lastSentAt = Date.now(); try { await this.client.createToolActivity(payload); } catch { // Tool Activity is best-effort; never disturb the agent turn. } } } /** * Build a safe, SQL-free summary of a remote Activity mirror failure for a * once-per-session warning. Includes the HTTP status when available so a 503 * schema_mismatch is recognizable without leaking internals. */ export function summarizeMirrorFailure(error: unknown): string { const status = typeof error === "object" && error !== null && "status" in error && typeof (error as { status?: unknown }).status === "number" ? (error as { status: number }).status : undefined; return status !== undefined ? `HTTP ${status}` : "remote unavailable"; }