import { statSync } from "node:fs"; import type { ExtensionAPI, ExtensionContext, ToolDefinition } from "@earendil-works/pi-coding-agent"; import { Text } from "@earendil-works/pi-tui"; import type { TObject } from "@sinclair/typebox"; import { TypeCompiler } from "@sinclair/typebox/compiler"; import { loadConfig } from "../../config/config.ts"; import { findClosestKey } from "../../config/suggestions.ts"; import type { MetricRegistry } from "../../observability/metric-registry.ts"; import type { createManifestCache } from "../../runtime/manifest-cache.ts"; import { TeamToolParams, type TeamToolParamsValue } from "../../schema/team-tool-schema.ts"; import { updatePiCrewPowerbar } from "../../ui/powerbar-publisher.ts"; import type { createRunSnapshotCache } from "../../ui/run-snapshot-cache.ts"; import { type ToolRenderContext, teamToolRenderer } from "../../ui/tool-renderers/index.ts"; import type { CrewWidgetState } from "../../ui/widget/index.ts"; import { updateCrewWidget } from "../../ui/widget/index.ts"; import { safeAbort } from "../../utils/safe-abort.ts"; import { resolveRealContainedPath } from "../../utils/safe-paths.ts"; // Team tool handler — lazy-loaded because team-tool.ts imports many modules import type { handleTeamTool as HandleTeamToolFn } from "../team-tool.ts"; async function handleTeamTool( params: Parameters[0], ctx: Parameters[1], ): Promise> { // LAZY: team-tool.ts imports many modules — defer until first use. const mod = await import("../team-tool.ts"); return mod.handleTeamTool(params, ctx); } import { readCrewAgents } from "../../runtime/crew-agent-records.ts"; import type { CrewAgentRecord } from "../../runtime/crew-agent-runtime.ts"; import { loadRunManifestById } from "../../state/stores/state-store.ts"; import { formatCompactToolProgress } from "../../ui/tool-progress-formatter.ts"; import { logInternalError } from "../../utils/internal-error.ts"; import { withSessionId } from "../team-tool/context.ts"; import { formatTeamToolParamError } from "../team-tool/param-error.ts"; import { toolResult } from "../tool-result.ts"; // RR-021 WI-4.3g: compile the team-tool schema ONCE. Value.Check re-walks the // whole (large) schema object on every tool call; TypeCompiler.Compile (STATIC // method on @sinclair/typebox/compiler) builds a specialized checker at module // load. The error formatter keeps using the schema + Value.Errors (cold path). const compiledTeamToolParams = TypeCompiler.Compile(TeamToolParams); /** * Normalize model-generated tool params before schema validation. * * Calling models (esp. Claude-style tool callers, incl. deepseek-via-commandcode) * routinely emit EVERY schema key with a default/empty value ("", 0, false) * rather than omitting it. Strict TypeBox unions/patterns then reject the call * even though the intent was "unset": e.g. `runId: ""` fails the * `^[A-Za-z0-9_-]+$` pattern, `workspaceMode: ""` fails the * Literal("single"|"worktree") union, `context: ""` / `scope: ""` likewise. * * Only EMPTY string values are dropped — non-empty strings, booleans, numbers, * arrays, objects are preserved verbatim so genuine invalid values still fail * validation with a helpful message. */ export function normalizeTeamParams(params: unknown): unknown { if (typeof params !== "object" || params === null || Array.isArray(params)) return params; const p = params as Record; const out: Record = {}; for (const [key, value] of Object.entries(p)) { if (typeof value === "string" && value.trim() === "") continue; // drop empty-string "unset" values out[key] = value; } return out; } const TEAM_TOOL_PROGRESS_TICK_MS = 1000; type OnUpdate = (chunk: { content: { type: "text"; text: string }[] }) => void; export interface RegisterTeamToolDeps { foregroundControllers: Map; startForegroundRun: (ctx: ExtensionContext, runner: (signal?: AbortSignal) => Promise, runId?: string) => void; abortForegroundRun: (runId: string) => boolean; openLiveSidebar: (ctx: ExtensionContext, runId: string) => void; getManifestCache: (cwd: string) => ReturnType; getRunSnapshotCache?: (cwd: string) => ReturnType; getMetricRegistry?: () => MetricRegistry | undefined; widgetState: CrewWidgetState; onJsonEvent?: (taskId: string, runId: string, event: unknown) => void; } export function resolveCwdOverride( baseCwd: string, override: string | undefined, ): { ok: true; cwd: string } | { ok: false; error: string } { if (!override) return { ok: true, cwd: baseCwd }; try { const resolved = resolveRealContainedPath(baseCwd, override); const stat = statSync(resolved); if (!stat.isDirectory()) return { ok: false, error: `cwd override is not a directory: ${resolved}`, }; return { ok: true, cwd: resolved }; } catch (error) { const message = error instanceof Error ? error.message : String(error); return { ok: false, error: `Invalid cwd override: ${message}` }; } } /** * EXT-1: detect unrecognized (likely typo'd) parameter field names. * * `TeamToolParams` is declared with `additionalProperties: true`, so TypeBox * `Value.Check` does NOT reject unknown keys. A mistyped call like * `{ action: "run", goals: "..." }` (typo `goals` vs `goal`) passes validation, * the handler then reads `params.goal` (undefined) and returns a generic * "Run requires goal or task." with ZERO hint about the typo — so calling * agents (LLMs) loop indefinitely on the same malformed shape. * * This scans `params` for keys absent from `schema.properties` (`action` is * excluded — it is validated separately by the schema enum) and, for each * unknown key, suggests the closest known field via Levenshtein distance. * * @returns A multi-line actionable error message, or `null` when every key * is recognized. */ export function detectUnrecognizedParams(schema: TObject, params: unknown): string | null { if (typeof params !== "object" || params === null || Array.isArray(params)) return null; const knownKeys = Object.keys(schema.properties ?? {}); const knownSet = new Set(knownKeys); const p = params as Record; const unknowns: string[] = []; for (const key of Object.keys(p)) { if (key === "action") continue; // validated separately by the schema action enum if (!knownSet.has(key)) unknowns.push(key); } if (unknowns.length === 0) return null; // Suggest the closest known field for each typo (action excluded as a candidate). const candidates = knownKeys.filter((k) => k !== "action"); const hints = unknowns.map((key) => { const closest = findClosestKey(key, candidates); return closest ? `Unrecognized field '${key}' — did you mean '${closest}'?` : `Unrecognized field '${key}'.`; }); return [ `Unrecognized team tool parameter field${unknowns.length > 1 ? "s" : ""}:`, ...hints.map((h) => ` • ${h}`), "", "Check the field name spelling against the schema. Common fields:", "action, goal, team, runId, task, role, agent, workflow, model, cwd.", ].join("\n"); } // PERF (2026-08-24): cheap read actions (status/summary/events/get/list) re-rendered // the widget + powerbar after every call; the event bus and render tick already // reflect run-state changes. Refresh only on actions that mutate run state // outside the watched files. Names mirror the five domain dispatcher tables in // src/schema/team-tool-schema.ts (RUN/STATUS/CONTROL/MANAGE/AUTOMATE). Actions // whose effect depends on sub-params (api: approve-plan/agent-control/mailbox; // plans: approve/reject; anchor/auto-summarize/auto_boomerang: set/clear/toggle; // config/autonomy: show vs write) are included — a redundant refresh on their // read sub-actions is cheap, a missed refresh after a write leaves stale UI. const MUTATING_ACTIONS = new Set([ // run domain "run", "parallel", "orchestrate", "resume", "retry", "steer", "goal", "plans", // control domain "cancel", "invalidate", "respond", "cleanup", "prune", "forget", // manage domain "create", "update", "delete", "init", "config", "autonomy", "settings", "workflow-create", "workflow-save", "workflow-delete", "import", // automate domain "schedule", "anchor", "auto-summarize", "auto_boomerang", "api", ]); export function registerTeamTool(pi: ExtensionAPI, deps: RegisterTeamToolDeps): void { const tool: ToolDefinition = { name: "team", label: "Team", // EXT-11: do NOT embed one-off performance benchmarks ("Run #3", "~30× faster", // "5.7× slower", etc.) in the tool description — they are stale, do not // generalize, and mislead the calling LLM about when to reach for the // team tool. Keep factual usage guidance (what the tool does, preflight // advisory behavior, action='recommend'). description: [ "Coordinate Pi teams. Use proactively for complex multi-file work, planning, implementation, tests, reviews, security audits, research, async/background runs, and worktree-isolated execution. Use action='recommend' when unsure which team/workflow to choose. Destructive actions require explicit user confirmation.", "", "ℹ️ ADVISORY NOTE (preflight, never blocks): pi-crew prints informational notes about workflow topology. Review the note, then decide. There is no BLOCK — agents always exercise judgment.", "- Single-task or 2–3 step sequential run: pi-crew notes that a raw `Agent` call may be simpler and faster, then proceeds anyway.", "- ≥3 concurrent or complex DAG: validated good use case; pi-crew notes 'validated use case'.", "If unsure, call { action: 'recommend', goal } first to get a team/workflow suggestion.", ].join("\n"), promptSnippet: [ "Use the team tool for multi-agent orchestration when you need ≥3 concurrent agents or a complex DAG.", "For single tasks or 2–3 sequential steps, the raw Agent tool is usually faster.", "pi-crew notes the topology (informational only) but proceeds either way — you decide based on your context (audit trail, team coordination, etc.).", "If unsure, call { action: 'recommend', goal } first.", ].join("\n"), parameters: TeamToolParams, async execute(_id, params, signal, onUpdate, ctx) { const controller = new AbortController(); const toolKey = Symbol(); deps.foregroundControllers.set(toolKey, controller); // safe-abort: pi's tool-call abort can race a completed run whose // spawned children already exited — Node's child_process signal // listener throws AbortError out of abort() and kills the host. const abort = (): void => safeAbort(controller, "team-tool.caller-abort"); signal?.addEventListener("abort", abort, { once: true }); const stopProgress = startTeamToolProgressBinder(onUpdate as OnUpdate | undefined); try { // Models emit empty-string defaults for unset keys; drop them BEFORE // validation so strict unions/patterns accept "unset" as omitted. const normalized = normalizeTeamParams(params); // Defense-in-depth: validate params at runtime even though Pi framework already does // (RR-021 WI-4.3g: precompiled checker — same semantics as Value.Check.) if (!compiledTeamToolParams.Check(normalized)) { return toolResult(formatTeamToolParamError(TeamToolParams, normalized), { action: "list", status: "error" }, true); } // EXT-1: additionalProperties:true lets unknown (typo'd) keys slip past // Value.Check. Catch them here and suggest the closest known field so // the caller gets an actionable hint instead of a generic handler error. const typoError = detectUnrecognizedParams(TeamToolParams, normalized); if (typoError) { return toolResult(typoError, { action: "list", status: "error" }, true); } const resolved = normalized as TeamToolParamsValue; const cwdOverride = resolveCwdOverride(ctx.cwd, resolved.cwd); if (!cwdOverride.ok) return toolResult(cwdOverride.error, { action: resolved.action ?? "list", status: "error" }, true); const toolCtx = withSessionId({ ...ctx, cwd: cwdOverride.cwd }); // Phase 1.5: Auto-set session name from team run context if (resolved.action === "run" && resolved.goal && !pi.getSessionName()) { const runLabel = resolved.team ?? resolved.agent ?? "direct"; pi.setSessionName(`pi-crew: ${runLabel}/${resolved.workflow ?? "default"} — ${resolved.goal.slice(0, 60)}`); } const output = await handleTeamTool(resolved, { ...toolCtx, signal: controller.signal, metricRegistry: deps.getMetricRegistry?.(), startForegroundRun: (runner, runId) => deps.startForegroundRun(toolCtx, runner, runId), abortForegroundRun: deps.abortForegroundRun, onRunStarted: (runId) => { stopProgress.attach(toolCtx.cwd, runId); deps.openLiveSidebar(toolCtx, runId); }, onJsonEvent: deps.onJsonEvent, getRunSnapshotCache: deps.getRunSnapshotCache, }); // W5: surface the team name on the collapsed run card — the renderer // reads `details.team`; enrich (non-destructively) for run results. if (resolved.action === "run" && !output.isError && output.details && typeof output.details === "object") { (output.details as unknown as Record).team ??= resolved.team ?? resolved.agent ?? ""; } if (resolved.action === "run" && !output.isError && typeof output.details?.runId === "string") { pi.appendEntry("crew:run-started", { runId: output.details.runId, team: resolved.team, workflow: resolved.workflow, agent: resolved.agent, goal: resolved.goal, status: output.details?.status, timestamp: Date.now(), }); } if (MUTATING_ACTIONS.has(resolved.action ?? "list")) { const config = loadConfig(toolCtx.cwd).config.ui; const cache = deps.getManifestCache(toolCtx.cwd); const snapshotCache = deps.getRunSnapshotCache?.(toolCtx.cwd); updateCrewWidget(toolCtx, deps.widgetState, config, cache, snapshotCache); updatePiCrewPowerbar(pi.events, toolCtx.cwd, config, cache, snapshotCache, toolCtx); } return output; } finally { signal?.removeEventListener("abort", abort); deps.foregroundControllers.delete(toolKey); stopProgress.stop(); } }, renderCall(args, theme, context) { return teamToolRenderer.renderCall(args as Record, theme, context as ToolRenderContext); }, renderResult(result, options, theme, context) { try { return teamToolRenderer.renderResult( result as unknown as Record, options, theme, context as ToolRenderContext, ); } catch (e) { // W1: fail-visible at the adapter layer too — never fake a "done". const msg = e instanceof Error ? e.message : String(e); return new Text(theme.fg("error", `✖ team render error: ${msg.slice(0, 60)}`), 0, 0); } }, }; pi.registerTool(tool); } interface TeamToolProgressBinder { attach: (cwd: string, runId: string) => void; stop: () => void; } function startTeamToolProgressBinder(onUpdate: OnUpdate | undefined): TeamToolProgressBinder { if (!onUpdate) { return { attach: () => undefined, stop: () => undefined }; } const startedAt = Date.now(); let cwd: string | undefined; let runId: string | undefined; const tick = (): void => { try { if (!cwd || !runId) { const elapsed = Math.max(0, Math.round((Date.now() - startedAt) / 1000)); const msg = `team status=starting elapsed=${elapsed}s`; onUpdate({ content: [{ type: "text", text: msg }] }); return; } const loaded = loadRunManifestById(cwd, runId); if (!loaded) { const elapsed = Math.max(0, Math.round((Date.now() - startedAt) / 1000)); const msg = `team run=${runId} elapsed=${elapsed}s (manifest pending)`; onUpdate({ content: [{ type: "text", text: msg }] }); return; } let agents: CrewAgentRecord[] | undefined; try { agents = readCrewAgents(loaded.manifest); } catch { /* ignore */ } const text = formatCompactToolProgress({ agentId: runId, status: loaded.manifest.status, runId, startedAt, manifest: loaded.manifest, tasks: loaded.tasks, agents, }); onUpdate({ content: [{ type: "text", text }] }); } catch (error) { logInternalError("team-tool.progress", error, `runId=${runId ?? ""}`); } }; tick(); const timer = setInterval(tick, TEAM_TOOL_PROGRESS_TICK_MS); if (typeof timer.unref === "function") timer.unref(); return { attach: (boundCwd: string, boundRunId: string) => { cwd = boundCwd; runId = boundRunId; tick(); }, stop: () => clearInterval(timer), }; }