import { randomUUID } from "node:crypto"; import { mkdirSync } from "node:fs"; import type { ChildProcess } from "child_process"; import type { ExtensionContext } from "@mariozechner/pi-coding-agent"; import type { Termination, spawnPiAgentWithModelFallback } from "../spawn.ts"; import type { TimelineEntry } from "../ui/zoom.ts"; import type { FleetTranscriptStore } from "../../lib/fleet-transcript-store.ts"; import type { HistoryEntry, ExecutionHistoryStore } from "../ui/history-store.ts"; import type { HubStateContext } from "../context/hub-state.ts"; import type { BudgetContext } from "../context/budgets.ts"; import type { InputArtifactPreview, AssertionsArtifactsContext } from "../context/assertions-artifacts.ts"; import type { createProviderSemaphore } from "../provider-semaphore.js"; import { safePathWithin } from "../helpers.ts"; import { runResearchSpawn, type ResearchSpawnPorts } from "./spawn-run.ts"; export const RESEARCH_TOOLS = "read,grep,find,ls"; export const MAX_AUTO_RESEARCH_ROUNDS = 2; export const MAX_AUTO_RESEARCH_QUESTIONS = 4; export const ANON_RESEARCH_PROMPT = `# Research Helper You are an ad-hoc read-only research helper assisting a team of specialist agents. Locate the relevant code or docs, read the surrounding context, and report concise, well-cited findings the rest of the team can act on.`; export interface ResearchAgentDef { name: string; description: string; tools: string; model?: string; fallbackModel?: string; thinking?: string; systemPrompt: string; file: string; } export interface ResearchState { id: number; dispatchId: string; sessionPath: string; evidenceDir: string; def: TDef; persona: boolean; model: string; status: "idle" | "running" | "done" | "error"; task: string; toolCount: number; messageCount: number; elapsed: number; lastWork: string; contextPct: number; contextTokens: number; timer?: ReturnType; proc?: ChildProcess; killedByOperator?: boolean; timeline: TimelineEntry[]; transcriptStore?: FleetTranscriptStore; zoomRender?: (force?: boolean) => void; histEntry?: HistoryEntry; } export interface ResearchResult { dispatchId?: string; evidencePath?: string; transcriptPath?: string; output: string; exitCode: number; elapsed: number; termination?: Termination; } export interface ResearchFinalizeOutcome { status: "idle" | "done" | "error"; historyStatus: "idle" | "done" | "error"; lastWork: string; } export interface ResearchRuntimeStatePorts { getResearchStates(): Map>; setResearchStates(value: Map>): void; getNextResearchId(): number; setNextResearchId(value: number): void; } export interface ResearchRuntimeDeps extends ResearchRuntimeStatePorts { hubState: Pick; budget: Pick; artifacts: Pick; executionHistory: ExecutionHistoryStore; providerSemaphore: ReturnType; getSafetyHarnessPath(): string | null; getReconSearchTimeoutMs(): number | null; getContextWindow(): number; resolvedModel(def: TDef): string | undefined; resolvedThinking(def: TDef): string | undefined; resolveThinkingLevel(value: string | undefined): string; fallbackModelFor(def: TDef, model: string): string | undefined; substitutedModel(model: string | undefined): string | undefined; modelWindowLookup(ctx: ExtensionContext): (provider: string, modelId: string) => unknown; guardrailEnv(agentId: string): Record; notifyProviderQueue(model: string, label: string, ctx: ExtensionContext): void; spawnPiAgentWithModelFallback: typeof spawnPiAgentWithModelFallback; nativeResearchSystemPrompt(input: { personaName?: string; personaPath?: string; cwd: string }): string; requireSafetyHarness(path: string | null): { ok: true; extensions: string[] } | { ok: false; error: string }; shortModel(model: string): string; displayName(name: string): string; flushTimelineStore(state: ResearchState): void; appendTimelineText(state: ResearchState, kind: "text" | "thinking", content: string): void; appendTimelineEvent(state: ResearchState, event: TimelineEntry): void; createTranscriptStore(path: string): FleetTranscriptStore; onElapsed?(): void; } export interface ResearchRuntime { states(): Map>; reset(): void; sessionPath(id: number): string; anonymousDef(): TDef; resolveModel(def: TDef, explicit: string | undefined, ctx: ExtensionContext): string; createState(def: TDef, persona: boolean, model: string): ResearchState; finalize(state: ResearchState, outcome: ResearchFinalizeOutcome): void; spawn(state: ResearchState, prompt: string, ctx: ExtensionContext, inputArtifacts?: InputArtifactPreview[], signal?: AbortSignal): Promise; } export function parseResearchHandle(arg: string): number | null { const match = arg.trim().match(/^#?r?(\d+)$/i); return match ? parseInt(match[1], 10) : null; } export function createResearchRuntime(deps: ResearchRuntimeDeps): ResearchRuntime { const paths = new Map(); const sessionPath = (id: number) => { const path = paths.get(id); if (!path) throw new Error(`Unknown research handle r${id}`); return path; }; const finalized = new WeakSet>(); const finalize = (state: ResearchState, outcome: ResearchFinalizeOutcome) => { if (finalized.has(state)) return; finalized.add(state); if (state.timer) { clearInterval(state.timer); state.timer = undefined; } state.proc = undefined; state.status = outcome.status; state.lastWork = outcome.lastWork; state.zoomRender?.(true); if (state.histEntry) { deps.executionHistory.end(state.histEntry, outcome.historyStatus); state.histEntry = undefined; } const states = deps.getResearchStates(); if (states.get(state.id) === state) states.delete(state.id); }; const spawnPorts: ResearchSpawnPorts = { ...deps, researchTools: RESEARCH_TOOLS, sessionPath, finalize }; return { states: deps.getResearchStates, reset() { paths.clear(); deps.setResearchStates(new Map()); deps.setNextResearchId(1); }, sessionPath, anonymousDef() { return { name: "research", description: "Ad-hoc read-only research helper.", tools: RESEARCH_TOOLS, systemPrompt: ANON_RESEARCH_PROMPT, file: "", } as TDef; }, resolveModel(def, explicit, ctx) { if (explicit) return explicit; return deps.resolvedModel(def) || (ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : "openrouter/google/gemini-3-flash-preview"); }, createState(def, persona, model) { const id = deps.getNextResearchId(); deps.setNextResearchId(id + 1); const dispatchId = randomUUID(); const evidenceDir = safePathWithin(deps.hubState.getSessionDir(), "dispatches", dispatchId); mkdirSync(evidenceDir, { recursive: true, mode: 0o700 }); const rawSession = safePathWithin(evidenceDir, "session.json"); paths.set(id, rawSession); const state: ResearchState = { id, dispatchId, evidenceDir, sessionPath: rawSession, def, persona, model, status: "running", task: "", toolCount: 0, messageCount: 0, elapsed: 0, lastWork: "", contextPct: 0, contextTokens: 0, timeline: [], }; deps.getResearchStates().set(id, state); return state; }, finalize, spawn(state, prompt, ctx, inputArtifacts = [], signal) { return runResearchSpawn(spawnPorts, state, prompt, ctx, inputArtifacts, signal); }, }; }