import { appendFileSync, renameSync, writeFileSync } from "node:fs"; import { randomUUID } from "node:crypto"; import { dirname, join } from "node:path"; import { homedir } from "node:os"; import type { AgentConfig, AgentRuntime, HiveState, HiveTeam } from "../core/types"; import type { HiveStateSnapshot, HiveTelemetryEvent, HiveTelemetryEventType, JsonRecord, TopologyNode } from "../shared/telemetry"; import { agentSlug, ensureDir, truncateMiddle } from "../core/utils"; import { currentAgentName } from "./session"; export type HiveObsEventType = HiveTelemetryEventType; export type HiveObsEvent

= HiveTelemetryEvent

; export function hiveTelemetryRegistryPath(): string { const base = process.env.PI_CODING_AGENT_DIR || join(homedir(), ".pi", "agent"); return join(base, "hive", "telemetry-sessions.jsonl"); } export function hiveTelemetryServerPidPath(): string { return join(dirname(hiveTelemetryRegistryPath()), "telemetry-server.json"); } export function registerHiveTelemetrySession(state: HiveState, cwd: string) { if (!state.session) return; const registryPath = hiveTelemetryRegistryPath(); ensureDir(dirname(registryPath)); appendFileSync(registryPath, `${JSON.stringify({ registered_at: new Date().toISOString(), session_id: state.session.sessionId, cwd, session_dir: state.session.sessionDir, conversation_log: state.session.conversationLog, telemetry_log: state.session.observabilityLog, state_file: join(state.session.sessionDir, "hive-state.json"), pid: process.pid, })}\n`); } function agentSummary(agent: AgentConfig): TopologyNode { return { slug: agentSlug(agent), name: agent.name, role: agent.role, agentType: agent.agentType, stages: agent.stages, group: agent.groupName, color: agent.color, model: agent.model, tools: agent.tools, thinking: agent.thinking, consultWhen: agent.consultWhen, routingTags: agent.routingTags || [], // The enforcement boundary (A8): the glob list the agent may write, whether // it may commit (presence of commit guidance unlocks the gate), and its // declared responsibilities. These are what Phase E renders and what the // versioned topology (Phase C) hashes. domain: (agent.domain || []).map((scope) => scope.path), commit: Boolean(agent.commit && agent.commit.trim()), responsibilities: (agent.responsibilities || []).join("\n") || undefined, children: [...(agent.members || []), ...(agent.children || [])].map(agentSummary), }; } function teamTopology(team?: HiveTeam): HiveStateSnapshot["topology"] | undefined { if (!team) return undefined; return { orchestrator: team.main ? agentSummary(team.main) : undefined, agents: (team.agents || []).map(agentSummary), }; } export function hiveTopology(state: HiveState): HiveStateSnapshot["topology"] { const roots = state.config?.agents || []; return { orchestrator: state.config?.orchestrator ? agentSummary(state.config.orchestrator) : undefined, agents: roots.map(agentSummary), }; } export function hiveTeamTopologies(state: HiveState): HiveStateSnapshot["topologies"] | undefined { if (!state.config) return undefined; return { active: state.mode === "plan" ? "planning" : "hive", hive: teamTopology(state.config.hive ?? { main: state.config.orchestrator, agents: state.config.agents }), planning: teamTopology(state.config.planning), }; } export function runtimeSummary(runtime: AgentRuntime): NonNullable[number] { return { slug: agentSlug(runtime.config), name: runtime.config.name, group: runtime.config.groupName || "Orchestration", role: runtime.config.role, agentType: runtime.config.agentType, status: runtime.status, task: runtime.task, lastWork: truncateMiddle(runtime.lastWork || "", 400), runCount: runtime.runCount, toolCount: runtime.toolCount, elapsedMs: runtime.elapsedMs, inputTokens: runtime.inputTokens, outputTokens: runtime.outputTokens, cacheReadTokens: runtime.cacheReadTokens, cacheWriteTokens: runtime.cacheWriteTokens, reasoningTokens: runtime.reasoningTokens, costUsd: runtime.costUsd, contextPct: runtime.contextPct, // Raw context-window fill behind contextPct (Phase 4.7) — carried through so // the dashboard can show tokens/window, not just the percentage. contextTokens: runtime.contextTokens, contextWindow: runtime.contextWindow, sessionFile: runtime.sessionFile, model: runtime.config.model, thinking: runtime.config.thinking, thinkingLevels: runtime.thinkingLevels, // Per-run token baselines for TOK/S (J8): the UI reads output live − output // baseline over elapsedMs so the generation rate reflects the current run, // not lifetime prompt volume. runStartInputTokens: runtime.runStartInputTokens, runStartOutputTokens: runtime.runStartOutputTokens, }; } // Overlay the accumulated orchestrator (main-session) usage onto the main // node's runtime summary so its tokens/cost/tool-calls are observable (A5). The // main node lives in state.runtimes as role "orchestrator" but its dispatch // counters stay zero (it is never delegated to); its real activity is tracked // on state.orchestratorRuntime by the hooks. function withOrchestratorUsage( state: HiveState, summary: NonNullable[number], ): NonNullable[number] { const orch = state.orchestratorRuntime; if (!orch || summary.role !== "orchestrator") return summary; return { ...summary, status: orch.status || summary.status, elapsedMs: orch.elapsedMs ?? summary.elapsedMs, runStartInputTokens: orch.runStartInputTokens ?? summary.runStartInputTokens, runStartOutputTokens: orch.runStartOutputTokens ?? summary.runStartOutputTokens, toolCount: (summary.toolCount || 0) + orch.toolCount, inputTokens: (summary.inputTokens || 0) + orch.inputTokens, outputTokens: (summary.outputTokens || 0) + orch.outputTokens, cacheReadTokens: (summary.cacheReadTokens || 0) + orch.cacheReadTokens, cacheWriteTokens: (summary.cacheWriteTokens || 0) + orch.cacheWriteTokens, reasoningTokens: (summary.reasoningTokens || 0) + orch.reasoningTokens, costUsd: (summary.costUsd || 0) + orch.costUsd, // Phase 4.3: the main session's live context fill, captured at each turn end. contextPct: orch.contextPct ?? summary.contextPct, // Phase 4.7: the raw tokens/window behind that percent, threaded through the // same overlay so the main node carries them like a worker does. contextTokens: orch.tokens ?? summary.contextTokens, contextWindow: orch.contextWindow ?? summary.contextWindow, }; } export function writeHiveStateSnapshot(state: HiveState) { if (!state.session || state.mode === "normal") return; const path = join(state.session.sessionDir, "hive-state.json"); ensureDir(dirname(path)); const snapshot: HiveStateSnapshot = { updated_at: new Date().toISOString(), session_id: state.session.sessionId, cwd: state.widgetCtx?.cwd, session_dir: state.session.sessionDir, telemetry_log: state.session.observabilityLog, conversation_log: state.session.conversationLog, topology: hiveTopology(state), topologies: hiveTeamTopologies(state), active_runs: state.activeRuns, agents: Array.from(state.runtimes.values()).map((runtime) => withOrchestratorUsage(state, runtimeSummary(runtime))), }; const tmp = `${path}.${process.pid}.tmp`; writeFileSync(tmp, JSON.stringify(snapshot)); renameSync(tmp, path); } // Distinct config-declared models across both teams (excluding "inherit"). Used // to scope the model_catalog to what this project actually references (A10). function configuredModels(state: HiveState): string[] { const models = new Set(); const visit = (node?: TopologyNode) => { if (!node) return; if (node.model && node.model !== "inherit") models.add(node.model); (node.children || []).forEach(visit); }; const teams = hiveTeamTopologies(state); for (const team of [teams?.hive, teams?.planning]) { if (!team) continue; visit(team.orchestrator); (team.agents || []).forEach(visit); } return [...models]; } // Emit one model_catalog event describing every model the active config // references, sourced from the SDK ModelRegistry (A10). Best-effort: if the // registry is unavailable the per-worker getAvailableThinkingLevels() path // (dispatch.ts) still supplies authoritative levels incrementally. export function emitModelCatalog(state: HiveState, registry: any, effectiveModel?: string) { if (!state.session || state.mode === "normal" || !registry?.getAll) return; const wanted = new Set(configuredModels(state)); // Include the session's current effective model (M1): `inherit` workers resolve // to it, so after a mid-session model switch the catalog must describe it even // when it isn't config-declared — otherwise those workers stay on an // undescribed model. `configuredModels` deliberately skips "inherit". if (effectiveModel && effectiveModel !== "inherit") wanted.add(effectiveModel); if (!wanted.size) return; let all: any[] = []; try { all = registry.getAll() || []; } catch { return; } const VOCAB = ["off", "minimal", "low", "medium", "high", "xhigh"]; const thinkingLevelsOf = (model: any): string[] => { if (!model?.reasoning) return ["off"]; const map = model?.thinkingLevelMap; // Mirror pi-ai's getSupportedThinkingLevels() semantics exactly. The model // registry is the source of truth; this function is only the telemetry // projection used by the dashboard cache. In pi-ai, an explicit null marks a // level unsupported, most missing entries remain supported, and xhigh is the // one level that must be explicitly mapped. return VOCAB.filter((level) => { const mapped = map && typeof map === "object" ? map[level] : undefined; if (mapped === null) return false; if (level === "xhigh") return mapped !== undefined; return true; }); }; // Never-drop: iterate the config's wanted models (source of truth) rather than // filtering the registry down to them. A registry hit enriches the row; a miss // still persists a best-effort row so the dashboard has a record for every // config model — an empty ladder degrades to plain text, not a missing dial. const byKey = new Map(); for (const model of all) byKey.set(`${model.provider}/${model.id}`, model); const models = [...wanted].map((key) => { const model = byKey.get(key); if (model) { return { provider: model.provider, modelId: model.id, name: model.name, api: model.api, reasoning: Boolean(model.reasoning), thinkingLevels: thinkingLevelsOf(model), contextWindow: model.contextWindow, maxTokens: model.maxTokens, costRates: model.cost ? { input: model.cost.input, output: model.cost.output, cacheRead: model.cost.cacheRead, cacheWrite: model.cost.cacheWrite, } : undefined, }; } // Miss: the registry doesn't know this model. Split provider/id from the // config string and emit a minimal row. The per-worker // getAvailableThinkingLevels() path can enrich thinking_levels later. const slash = key.indexOf("/"); const provider = slash >= 0 ? key.slice(0, slash) : key; const modelId = slash >= 0 ? key.slice(slash + 1) : key; return { provider, modelId, name: undefined, api: undefined, reasoning: false, thinkingLevels: [] as string[], contextWindow: undefined, maxTokens: undefined, costRates: undefined, }; }); if (models.length) emitHiveEvent(state, "model_catalog", { models }, "System"); } export function startHiveTelemetrySession(state: HiveState, cwd: string) { if (!state.session || state.mode === "normal" || state.telemetryRegistered) return; state.telemetryRegistered = true; registerHiveTelemetrySession(state, cwd); // Phase 2.3: do NOT embed the full topology tree here. It was redundant with // topology_versions — the snapshot written immediately below is hashed, // versioned, and stamped onto the session by the daemon (runtime.ts), and no // consumer reads session_start.payload.topology. Keeping it duplicated the // whole tree on every session and drifted from the canonical hashed copy. emitHiveEvent(state, "session_start", { cwd, sessionDir: state.session.sessionDir, conversationLog: state.session.conversationLog, observabilityLog: state.session.observabilityLog, }, "System"); writeHiveStateSnapshot(state); // Emit the model catalog at the first stable point where telemetry is live // (session set, mode no longer normal, log open). Uses the registry handle // captured from the full session_start ctx, so the pillar dial always has // level data. Idempotent by content hash, so re-running per session is cheap. emitModelCatalog(state, state.modelRegistry); } export function emitHiveEvent(state: HiveState, type: HiveObsEventType, payload: JsonRecord = {}, actor = currentAgentName()) { if (!state.session || state.mode === "normal") return; const logPath = state.session.observabilityLog; if (!logPath) return; ensureDir(dirname(logPath)); const event: HiveObsEvent = { event_id: randomUUID(), ts: new Date().toISOString(), type, session_id: state.session.sessionId, cwd: state.widgetCtx?.cwd, session_dir: state.session.sessionDir, telemetry_log: state.session.observabilityLog, actor, pid: process.pid, seq: state.obsSeq++, payload, }; appendFileSync(logPath, `${JSON.stringify(event)}\n`); }