import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync, } from "node:fs"; import { access, mkdir, readFile, readdir, writeFile, } from "node:fs/promises"; import { homedir } from "node:os"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import type { ExtensionAPI, ExtensionContext, ToolCallEventResult, } from "@earendil-works/pi-coding-agent"; import { matchesKey, truncateToWidth } from "@earendil-works/pi-tui"; import { ensureSddPreflight, getSddPreflightPreferences, installSddAssets, isSddPreflightTrigger, renderSddPreflightPrompt, type SddPreflightPreferences, } from "../lib/sdd-preflight.ts"; const PACKAGE_ROOT = dirname(dirname(fileURLToPath(import.meta.url))); const ASSETS_DIR = join(PACKAGE_ROOT, "assets"); function gentlePiAgentHome(): string { return process.env.GENTLE_PI_AGENT_HOME ?? join(homedir(), ".pi", "agent"); } function sddGlobalAssetDriftCount(): number { let stale = 0; for (const [assetSubdir, installedSubdir] of [ ["agents", "agents"], ["chains", "chains"], ] as const) { const assetDir = join(ASSETS_DIR, assetSubdir); if (!existsSync(assetDir)) continue; for (const entry of readdirSync(assetDir, { withFileTypes: true })) { if (!entry.isFile()) continue; const installedPath = join(gentlePiAgentHome(), installedSubdir, entry.name); try { if (!existsSync(installedPath)) { stale += 1; continue; } if ( readFileSync(join(assetDir, entry.name), "utf8") !== readFileSync(installedPath, "utf8") ) { stale += 1; } } catch { stale += 1; } } } return stale; } function sddLocalOverrideDriftCount(cwd: string): number { let stale = 0; for (const [assetSubdir, installedSubdir] of [ ["agents", join(".pi", "agents")], ["chains", join(".pi", "chains")], ] as const) { const assetDir = join(ASSETS_DIR, assetSubdir); const installedDir = join(cwd, installedSubdir); if (!existsSync(assetDir) || !existsSync(installedDir)) continue; for (const entry of readdirSync(assetDir, { withFileTypes: true })) { if (!entry.isFile()) continue; const installedPath = join(installedDir, entry.name); if (!existsSync(installedPath)) continue; try { if ( readFileSync(join(assetDir, entry.name), "utf8") !== readFileSync(installedPath, "utf8") ) { stale += 1; } } catch { stale += 1; } } } return stale; } let orchestratorPromptCache: string | null = null; function getOrchestratorPrompt(): string { if (orchestratorPromptCache === null) { orchestratorPromptCache = readFileSync( join(ASSETS_DIR, "orchestrator.md"), "utf8", ).trim(); } return orchestratorPromptCache; } async function pathExists(path: string): Promise { try { await access(path); return true; } catch { return false; } } type PersonaMode = "gentleman" | "neutral"; const PERSONA_OPTIONS = ["gentleman", "neutral"] as const; const GENTLEMAN_PERSONA_PROMPT = `Persona: - Be direct, technical, and concise. - When the user writes Spanish, answer in natural Rioplatense Spanish with voseo. - Act as a senior architect and teacher: concepts before code, no shortcuts. - Treat AI as a tool directed by the human; never present yourself as a default chatbot. - Push back when the user asks for code without enough context or understanding. - Correct errors directly, explain why, and show the better path.`; const NEUTRAL_PERSONA_PROMPT = `Persona: - Be direct, technical, concise, warm, and professional. - Always respond in the same language the user writes in. - Do not use slang or regional expressions. - Act as a senior architect and teacher: concepts before code, no shortcuts. - Treat AI as a tool directed by the human; never present yourself as a default chatbot. - Push back when the user asks for code without enough context or understanding. - Correct errors directly, explain why, and show the better path.`; function buildGentlePrompt(persona: PersonaMode): string { const personaPrompt = persona === "neutral" ? NEUTRAL_PERSONA_PROMPT : GENTLEMAN_PERSONA_PROMPT; return `## el Gentleman Identity and Harness You are el Gentleman: a Pi-specific coding-agent harness for controlled development work. Identity contract: - If the user asks who or what you are, answer as el Gentleman, not as a generic assistant. - Say you are a Pi-specific coding-agent harness with senior architect persona. - Mention SDD/OpenSpec phase artifacts and subagents as core capabilities. - Mention memory only when memory packages or callable memory tools are actually active; never invent persistent memory. - Do not claim portability outside the Pi runtime. ${personaPrompt} Harness principles: - el Gentleman is not prompt engineering. It is runtime discipline around powerful agents. - Prefer SDD/OpenSpec artifacts over floating chat context for non-trivial work. - Clarify scope, constraints, acceptance criteria, and non-goals before implementation. - Use subagents when available for exploration, planning, implementation, and review, while keeping one parent session responsible for orchestration. - Keep writes single-threaded unless the user explicitly approves parallel write isolation. - If tests exist, use strict TDD evidence: RED, GREEN, TRIANGULATE, REFACTOR. - Protect the human reviewer: avoid oversized changes, surface review workload risk, and ask before turning one task into a large multi-area change. - Never claim persistent memory is available because of this package. Memory is provided by separate packages or MCP tools when installed and callable. ${getOrchestratorPrompt()}`; } const DENIED_BASH_PATTERNS: RegExp[] = [ /\brm\s+-rf\s+(?:\/|~|\$HOME|\.\.?)(?:\s|$)/, /\bgit\s+reset\s+--hard\b/, /\bgit\s+clean\b(?=[^\n]*(?:-[^\n]*f|--force))(?=[^\n]*(?:-[^\n]*d|--directories))/, /\bgit\s+push\b(?=[^\n]*\s--force(?:-with-lease)?\b)/, /\bchmod\s+-R\s+777\b/, /\bchown\s+-R\b/, ]; const CONFIRM_BASH_PATTERNS: RegExp[] = [ /\bgit\s+push\b/, /\bgit\s+rebase\b/, /\bgit\s+branch\s+-D\b/, /\bnpm\s+publish\b/, /\bpi\s+remove\b/, ]; const SDD_AGENT_NAMES = [ "sdd-init", "sdd-explore", "sdd-proposal", "sdd-spec", "sdd-design", "sdd-tasks", "sdd-apply", "sdd-verify", "sdd-sync", "sdd-archive", ] as const; const SDD_AGENT_NAME_SET = new Set(SDD_AGENT_NAMES); type SddAgentName = (typeof SDD_AGENT_NAMES)[number]; type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh"; interface AgentRoutingEntry { model?: string; thinking?: ThinkingLevel; } type AgentModelConfig = Record; type ModelConfigFileResult = | { status: "missing" } | { status: "invalid"; path: string } | { status: "valid"; config: AgentModelConfig }; type AgentSource = "project" | "user" | "builtin"; interface AgentEntry { name: string; source: AgentSource; filePath?: string; } const KEEP_CURRENT = "Keep current"; const INHERIT_MODEL = "Inherit active/default model"; const CUSTOM_MODEL = "Custom model id"; const INHERIT_THINKING = "Inherit effort"; const THINKING_OPTIONS: (ThinkingLevel | typeof INHERIT_THINKING)[] = [ INHERIT_THINKING, "off", "minimal", "low", "medium", "high", "xhigh", ]; const MODEL_CONTROL_OPTIONS = [ KEEP_CURRENT, INHERIT_MODEL, CUSTOM_MODEL, ] as const; const MODEL_PANEL_MAX_RENDER_ROWS = 20; const AGENT_LIST_MAX_VISIBLE_ROWS = MODEL_PANEL_MAX_RENDER_ROWS - 13; const MODEL_LIST_MAX_VISIBLE_ROWS = 12; function readStringPath(value: unknown, path: string[]): string | undefined { let current = value; for (const key of path) { if (!isRecord(current)) return undefined; current = current[key]; } return typeof current === "string" ? current : undefined; } function isSddAgentStartEvent(event: unknown): boolean { const candidates = readAgentStartNames(event); if (candidates.some((value) => SDD_AGENT_NAME_SET.has(value))) return true; const systemPrompt = readStringPath(event, ["systemPrompt"]) ?? ""; return SDD_AGENT_NAMES.some((name) => { const phase = name.replace(/^sdd-/, ""); return new RegExp(`\\bSDD ${phase} executor\\b`, "i").test(systemPrompt); }); } function readAgentStartNames(event: unknown): string[] { return [ readStringPath(event, ["agentName"]), readStringPath(event, ["agent"]), readStringPath(event, ["name"]), readStringPath(event, ["agent", "name"]), readStringPath(event, ["subagent", "name"]), ] .filter((value): value is string => value !== undefined) .map((value) => value.trim()) .filter((value) => value.length > 0); } function isNamedAgentStartEvent(event: unknown): boolean { return readAgentStartNames(event).length > 0; } function evaluateDeniedCommand( command: string, ): ToolCallEventResult | undefined { for (const pattern of DENIED_BASH_PATTERNS) { if (pattern.test(command)) { return { block: true, reason: "Gentle AI safety policy blocked a destructive shell command. Ask the user for an explicit safer plan.", }; } } return undefined; } function commandRequiresConfirmation(command: string): boolean { return CONFIRM_BASH_PATTERNS.some((pattern) => pattern.test(command)); } async function confirmCommand( command: string, ctx: ExtensionContext, ): Promise { const denied = evaluateDeniedCommand(command); if (denied) return denied; if (!commandRequiresConfirmation(command)) return undefined; if (!ctx.hasUI) { return { block: true, reason: "Gentle AI safety policy requires interactive confirmation before this command.", }; } const preview = truncateToWidth( command.replace(/\s+/g, " ").trim(), 180, "…", ); const approved = await ctx.ui.confirm("Allow guarded command?", preview); if (approved) return undefined; return { block: true, reason: "Gentle AI safety policy blocked the command because it was not confirmed.", }; } function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } function gentleAiConfigHome(): string { return process.env.GENTLE_PI_CONFIG_HOME ?? join(homedir(), ".pi", "gentle-ai"); } function modelConfigPath(_cwd: string): string { return join(gentleAiConfigHome(), "models.json"); } function legacyProjectModelConfigPath(cwd: string): string { return join(cwd, ".pi", "gentle-ai", "models.json"); } function personaConfigPath(cwd: string): string { return join(cwd, ".pi", "gentle-ai", "persona.json"); } function readPersonaMode(cwd: string): PersonaMode { const path = personaConfigPath(cwd); if (!existsSync(path)) return "gentleman"; try { const parsed: unknown = JSON.parse(readFileSync(path, "utf8")); if (!isRecord(parsed)) return "gentleman"; return parsed.mode === "neutral" ? "neutral" : "gentleman"; } catch { return "gentleman"; } } function writePersonaMode(cwd: string, mode: PersonaMode): void { const path = personaConfigPath(cwd); mkdirSync(dirname(path), { recursive: true }); writeFileSync(path, `${JSON.stringify({ mode }, null, 2)}\n`); } function isThinkingLevel(value: unknown): value is ThinkingLevel { return ( value === "off" || value === "minimal" || value === "low" || value === "medium" || value === "high" || value === "xhigh" ); } const ANSI_ESCAPE_PATTERN = /[\u001b\u009b][[\]()#;?]*(?:(?:(?:[a-zA-Z\d]*(?:;[a-zA-Z\d]*)*)?\u0007)|(?:(?:\d{1,4}(?:;\d{0,4})*)?[\dA-PR-TZcf-nq-uy=><~]))/g; const CONTROL_CHAR_PATTERN = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/g; const SAFE_MODEL_ID_PATTERN = /^[A-Za-z0-9._~:@/+%-]+$/; function sanitizeTerminalText(value: string): string { return value .replace(ANSI_ESCAPE_PATTERN, "") .replace(CONTROL_CHAR_PATTERN, ""); } function normalizeModelId(value: unknown): string | undefined { if (typeof value !== "string") return undefined; const model = value.trim(); if (model.length === 0) return undefined; if (!SAFE_MODEL_ID_PATTERN.test(model)) return undefined; return model; } function normalizeRoutingEntry(value: unknown): AgentRoutingEntry | undefined { if (typeof value === "string") { const model = normalizeModelId(value); return model ? { model } : undefined; } if (!isRecord(value)) return undefined; const model = normalizeModelId(value.model); const thinking = isThinkingLevel(value.thinking) ? value.thinking : undefined; if (!model && !thinking) return undefined; return { model, thinking }; } function readModelConfigFile(path: string): ModelConfigFileResult { if (!existsSync(path)) return { status: "missing" }; try { const parsed: unknown = JSON.parse(readFileSync(path, "utf8")); if (!isRecord(parsed)) return { status: "invalid", path }; const config: AgentModelConfig = {}; for (const [name, value] of Object.entries(parsed)) { const entry = normalizeRoutingEntry(value); if (entry) config[name] = entry; } return { status: "valid", config }; } catch { return { status: "invalid", path }; } } async function readModelConfigFileAsync( path: string, ): Promise { if (!(await pathExists(path))) return { status: "missing" }; try { const parsed: unknown = JSON.parse(await readFile(path, "utf8")); if (!isRecord(parsed)) return { status: "invalid", path }; const config: AgentModelConfig = {}; for (const [name, value] of Object.entries(parsed)) { const entry = normalizeRoutingEntry(value); if (entry) config[name] = entry; } return { status: "valid", config }; } catch { return { status: "invalid", path }; } } function readSavedModelConfig(cwd: string): ModelConfigFileResult { const globalResult = readModelConfigFile(modelConfigPath(cwd)); if (globalResult.status !== "missing") return globalResult; const legacyResult = readModelConfigFile(legacyProjectModelConfigPath(cwd)); if (legacyResult.status === "invalid") return { status: "valid", config: {} }; return legacyResult; } async function readSavedModelConfigAsync( cwd: string, ): Promise { const globalResult = await readModelConfigFileAsync(modelConfigPath(cwd)); if (globalResult.status !== "missing") return globalResult; const legacyResult = await readModelConfigFileAsync( legacyProjectModelConfigPath(cwd), ); if (legacyResult.status === "invalid") return { status: "valid", config: {} }; return legacyResult; } export function readModelConfig(cwd: string): AgentModelConfig { const result = readSavedModelConfig(cwd); return result.status === "valid" ? result.config : {}; } export async function readModelConfigAsync( cwd: string, ): Promise { const result = await readSavedModelConfigAsync(cwd); return result.status === "valid" ? result.config : {}; } function writeModelConfig(cwd: string, config: AgentModelConfig): void { const path = modelConfigPath(cwd); mkdirSync(dirname(path), { recursive: true }); const cleaned: AgentModelConfig = {}; for (const [name, value] of Object.entries(config)) { const entry = normalizeRoutingEntry(value); if (entry) cleaned[name] = entry; } writeFileSync(path, `${JSON.stringify(cleaned, null, 2)}\n`); } function cloneModelConfig(config: AgentModelConfig): AgentModelConfig { return Object.fromEntries( Object.entries(config).map(([name, entry]) => [name, { ...entry }]), ); } function updateFrontmatterRouting( content: string, entry: AgentRoutingEntry | undefined, ): string { if (!content.startsWith("---\n")) return content; const endIndex = content.indexOf("\n---", 4); if (endIndex === -1) return content; const frontmatter = content.slice(4, endIndex); const body = content.slice(endIndex); const lines = frontmatter .split("\n") .filter( (line) => !line.startsWith("model:") && !line.startsWith("thinking:"), ); const toInsert: string[] = []; if (entry?.model) toInsert.push(`model: ${entry.model}`); if (entry?.thinking) toInsert.push(`thinking: ${entry.thinking}`); if (toInsert.length > 0) { const descriptionIndex = lines.findIndex((line) => line.startsWith("description:"), ); const insertIndex = descriptionIndex >= 0 ? descriptionIndex + 1 : Math.min(1, lines.length); lines.splice(insertIndex, 0, ...toInsert); } return `---\n${lines.join("\n")}${body}`; } function parseAgentName(filePath: string): string | undefined { let content: string; try { content = readFileSync(filePath, "utf8"); } catch { return undefined; } const name = content.match(/^name:\s*["']?([^"'\n]+)["']?\s*$/m)?.[1]?.trim(); if (!name) return undefined; const packageName = content .match(/^package:\s*["']?([^"'\n]+)["']?\s*$/m)?.[1] ?.trim(); return packageName ? `${packageName}.${name}` : name; } async function parseAgentNameAsync( filePath: string, ): Promise { let content: string; try { content = await readFile(filePath, "utf8"); } catch { return undefined; } const name = content.match(/^name:\s*["']?([^"'\n]+)["']?\s*$/m)?.[1]?.trim(); if (!name) return undefined; const packageName = content .match(/^package:\s*["']?([^"'\n]+)["']?\s*$/m)?.[1] ?.trim(); return packageName ? `${packageName}.${name}` : name; } function listAgentFilesRecursive(dir: string): string[] { if (!existsSync(dir)) return []; const files: string[] = []; for (const entry of readdirSync(dir, { withFileTypes: true })) { const path = join(dir, entry.name); if (entry.isDirectory()) files.push(...listAgentFilesRecursive(path)); else if ( entry.isFile() && entry.name.endsWith(".md") && !entry.name.endsWith(".chain.md") ) files.push(path); } return files; } async function listAgentFilesRecursiveAsync(dir: string): Promise { if (!(await pathExists(dir))) return []; const files: string[] = []; let entries; try { entries = await readdir(dir, { withFileTypes: true }); } catch { return files; } for (const entry of entries) { const path = join(dir, entry.name); if (entry.isDirectory()) { files.push(...(await listAgentFilesRecursiveAsync(path))); } else if ( entry.isFile() && entry.name.endsWith(".md") && !entry.name.endsWith(".chain.md") ) { files.push(path); } } return files; } function listAgentsFromDir(dir: string, source: AgentSource): AgentEntry[] { return listAgentFilesRecursive(dir) .map((filePath): AgentEntry | undefined => { const name = parseAgentName(filePath); return name ? { name, source, filePath } : undefined; }) .filter((entry): entry is AgentEntry => entry !== undefined); } async function listAgentsFromDirAsync( dir: string, source: AgentSource, ): Promise { const filePaths = await listAgentFilesRecursiveAsync(dir); const entries: AgentEntry[] = []; for (const filePath of filePaths) { const name = await parseAgentNameAsync(filePath); if (name) entries.push({ name, source, filePath }); } return entries; } function listDiscoverableAgents(cwd: string): AgentEntry[] { const builtinDirs = [ join(gentlePiAgentHome(), "agents"), join(PACKAGE_ROOT, "..", "pi-subagents", "agents"), join(cwd, ".pi", "npm", "node_modules", "pi-subagents", "agents"), join(homedir(), ".local", "lib", "node_modules", "pi-subagents", "agents"), ]; const agents = [ ...builtinDirs.flatMap((dir) => listAgentsFromDir(dir, "builtin")), ...listAgentsFromDir(join(homedir(), ".agents"), "user"), ...listAgentsFromDir(join(cwd, ".agents"), "project"), ...listAgentsFromDir(join(cwd, ".pi", "agents"), "project"), ]; const byName = new Map(); for (const agent of agents) byName.set(agent.name, agent); const discovered = Array.from(byName.values()); const sddFirst = SDD_AGENT_NAMES.map((name) => discovered.find((agent) => agent.name === name), ).filter((agent): agent is AgentEntry => agent !== undefined); const rest = discovered .filter((agent) => !SDD_AGENT_NAMES.includes(agent.name as SddAgentName)) .sort((left, right) => left.name.localeCompare(right.name)); return [...sddFirst, ...rest]; } async function listDiscoverableAgentsAsync(cwd: string): Promise { const builtinDirs = [ join(gentlePiAgentHome(), "agents"), join(PACKAGE_ROOT, "..", "pi-subagents", "agents"), join(cwd, ".pi", "npm", "node_modules", "pi-subagents", "agents"), join(homedir(), ".local", "lib", "node_modules", "pi-subagents", "agents"), ]; const agents: AgentEntry[] = []; for (const dir of builtinDirs) { agents.push(...(await listAgentsFromDirAsync(dir, "builtin"))); } const otherDirs: Array<[string, AgentSource]> = [ [join(homedir(), ".agents"), "user"], [join(cwd, ".agents"), "project"], [join(cwd, ".pi", "agents"), "project"], ]; for (const [dir, source] of otherDirs) { agents.push(...(await listAgentsFromDirAsync(dir, source))); } const byName = new Map(); for (const agent of agents) byName.set(agent.name, agent); const discovered = Array.from(byName.values()); const sddFirst = SDD_AGENT_NAMES.map((name) => discovered.find((agent) => agent.name === name), ).filter((agent): agent is AgentEntry => agent !== undefined); const rest = discovered .filter((agent) => !SDD_AGENT_NAMES.includes(agent.name as SddAgentName)) .sort((left, right) => left.name.localeCompare(right.name)); return [...sddFirst, ...rest]; } function projectSettingsPath(cwd: string): string { return join(cwd, ".pi", "settings.json"); } function updateBuiltinModelOverride( cwd: string, name: string, entry: AgentRoutingEntry | undefined, ): boolean { const path = projectSettingsPath(cwd); let settings: Record = {}; if (existsSync(path)) { try { const parsed: unknown = JSON.parse(readFileSync(path, "utf8")); if (isRecord(parsed)) settings = parsed; } catch { settings = {}; } } const subagents = isRecord(settings.subagents) ? { ...settings.subagents } : {}; const agentOverrides = isRecord(subagents.agentOverrides) ? { ...subagents.agentOverrides } : {}; const current = isRecord(agentOverrides[name]) ? { ...agentOverrides[name] } : {}; if (entry?.model === undefined) delete current.model; else current.model = entry.model; if (entry?.thinking === undefined) delete current.thinking; else current.thinking = entry.thinking; if (Object.keys(current).length > 0) agentOverrides[name] = current; else delete agentOverrides[name]; if (Object.keys(agentOverrides).length > 0) subagents.agentOverrides = agentOverrides; else delete subagents.agentOverrides; if (Object.keys(subagents).length > 0) settings.subagents = subagents; else delete settings.subagents; mkdirSync(dirname(path), { recursive: true }); writeFileSync(path, `${JSON.stringify(settings, null, "\t")}\n`); return true; } async function updateBuiltinModelOverrideAsync( cwd: string, name: string, entry: AgentRoutingEntry | undefined, ): Promise { const path = projectSettingsPath(cwd); let settings: Record = {}; if (await pathExists(path)) { try { const parsed: unknown = JSON.parse(await readFile(path, "utf8")); if (isRecord(parsed)) settings = parsed; } catch { settings = {}; } } const subagents = isRecord(settings.subagents) ? { ...settings.subagents } : {}; const agentOverrides = isRecord(subagents.agentOverrides) ? { ...subagents.agentOverrides } : {}; const current = isRecord(agentOverrides[name]) ? { ...agentOverrides[name] } : {}; if (entry?.model === undefined) delete current.model; else current.model = entry.model; if (entry?.thinking === undefined) delete current.thinking; else current.thinking = entry.thinking; if (Object.keys(current).length > 0) agentOverrides[name] = current; else delete agentOverrides[name]; if (Object.keys(agentOverrides).length > 0) subagents.agentOverrides = agentOverrides; else delete subagents.agentOverrides; if (Object.keys(subagents).length > 0) settings.subagents = subagents; else delete settings.subagents; await mkdir(dirname(path), { recursive: true }); await writeFile(path, `${JSON.stringify(settings, null, "\t")}\n`); return true; } export function applyModelConfig( cwd: string, config: AgentModelConfig, ): { updated: number; skipped: number } { let updated = 0; let skipped = 0; for (const agent of listDiscoverableAgents(cwd)) { const entry = config[agent.name]; if (agent.source === "builtin") { if (updateBuiltinModelOverride(cwd, agent.name, entry)) updated += 1; else skipped += 1; continue; } if (!agent.filePath || !existsSync(agent.filePath)) { skipped += 1; continue; } const original = readFileSync(agent.filePath, "utf8"); const next = updateFrontmatterRouting(original, entry); if (next === original) { skipped += 1; continue; } writeFileSync(agent.filePath, next); updated += 1; } return { updated, skipped }; } export async function applyModelConfigAsync( cwd: string, config: AgentModelConfig, ): Promise<{ updated: number; skipped: number }> { let updated = 0; let skipped = 0; for (const agent of await listDiscoverableAgentsAsync(cwd)) { const entry = config[agent.name]; if (agent.source === "builtin") { if (await updateBuiltinModelOverrideAsync(cwd, agent.name, entry)) updated += 1; else skipped += 1; continue; } if (!agent.filePath || !(await pathExists(agent.filePath))) { skipped += 1; continue; } const original = await readFile(agent.filePath, "utf8"); const next = updateFrontmatterRouting(original, entry); if (next === original) { skipped += 1; continue; } await writeFile(agent.filePath, next); updated += 1; } return { updated, skipped }; } export async function applySavedModelConfig( ctx: ExtensionContext, ): Promise<{ updated: number; skipped: number; invalidPath?: string }> { const result = await readSavedModelConfigAsync(ctx.cwd); if (result.status === "invalid") { return { updated: 0, skipped: 0, invalidPath: result.path }; } return applyModelConfigAsync( ctx.cwd, result.status === "valid" ? result.config : {}, ); } function describeModelConfig(cwd: string, config: AgentModelConfig): string[] { return listDiscoverableAgents(cwd).map((agent) => { const entry = config[agent.name]; const model = entry?.model ?? "inherit"; const thinking = entry?.thinking ?? "inherit"; return `${sanitizeTerminalText(agent.name)}: model=${sanitizeTerminalText(model)}, effort=${sanitizeTerminalText(thinking)}`; }); } async function getPiModelOptions(ctx: ExtensionContext): Promise { const models = await ctx.modelRegistry.getAvailable(); const modelIds = models .map((model) => normalizeModelId(`${model.provider}/${model.id}`)) .filter((model): model is string => model !== undefined) .sort((left, right) => left.localeCompare(right)); return [...MODEL_CONTROL_OPTIONS, ...modelIds]; } interface OverlayComponent { render(width: number): string[]; handleInput(data: string): void; invalidate(): void; } type ModelPanelResult = | { type: "save"; config: AgentModelConfig } | { type: "custom"; agent: string | "all"; config: AgentModelConfig } | { type: "cancel" }; const SET_ALL_AGENTS = "Set all agents"; class SddModelPanel implements OverlayComponent { private cursor = 0; private mode: "agents" | "models" | "effort" = "agents"; private selectedRow = SET_ALL_AGENTS; private modelCursor = 0; private effortCursor = 0; private query = ""; private readonly draft: AgentModelConfig; private readonly rows: string[]; private readonly modelOptions: string[]; private readonly done: (result: ModelPanelResult) => void; constructor( initialConfig: AgentModelConfig, modelOptions: string[], agents: string[], done: (result: ModelPanelResult) => void, ) { this.draft = cloneModelConfig(initialConfig); this.rows = [SET_ALL_AGENTS, ...agents]; this.modelOptions = modelOptions; this.done = done; } invalidate(): void {} handleInput(data: string): void { if (this.mode === "models") { this.handleModelInput(data); return; } if (this.mode === "effort") { this.handleEffortInput(data); return; } this.handleAgentInput(data); } render(width: number): string[] { const innerWidth = Math.max(1, width - 4); const lines = this.mode === "models" ? this.renderModelPicker(innerWidth) : this.mode === "effort" ? this.renderEffortPicker(innerWidth) : this.renderAgentList(innerWidth); return this.renderCard(lines, width); } private renderCard(lines: string[], width: number): string[] { const innerWidth = Math.max(1, width - 4); const fit = (text = "") => truncateToWidth(sanitizeTerminalText(text), innerWidth, "…", true).padEnd(innerWidth); return [ `╭${"─".repeat(innerWidth + 2)}╮`, ...lines.map((line) => `│ ${fit(line)} │`), `╰${"─".repeat(innerWidth + 2)}╯`, ]; } private handleAgentInput(data: string): void { const maxCursor = this.rows.length + 1; if (matchesKey(data, "ctrl+c") || matchesKey(data, "escape")) { this.done({ type: "cancel" }); return; } if (matchesKey(data, "ctrl+s")) { this.done({ type: "save", config: this.draft }); return; } if (matchesKey(data, "down") || matchesKey(data, "j")) { this.cursor = Math.min(maxCursor, this.cursor + 1); return; } if (matchesKey(data, "up") || matchesKey(data, "k")) { this.cursor = Math.max(0, this.cursor - 1); return; } if (matchesKey(data, "g")) { this.cursor = 0; return; } if (data === "G") { this.cursor = maxCursor; return; } if (matchesKey(data, "i")) { this.applyInherit(); return; } if (matchesKey(data, "e")) { this.selectedRow = this.rows[this.cursor] ?? SET_ALL_AGENTS; this.mode = "effort"; this.effortCursor = 0; return; } if (matchesKey(data, "c")) { const row = this.rows[this.cursor]; if (row === SET_ALL_AGENTS) this.done({ type: "custom", agent: "all", config: this.draft }); else if (row) this.done({ type: "custom", agent: row, config: this.draft }); return; } if (!matchesKey(data, "return")) return; if (this.cursor === this.rows.length) { this.done({ type: "save", config: this.draft }); return; } if (this.cursor === this.rows.length + 1) { this.done({ type: "cancel" }); return; } this.selectedRow = this.rows[this.cursor] ?? SET_ALL_AGENTS; this.mode = "models"; this.modelCursor = 0; this.query = ""; } private handleModelInput(data: string): void { const options = this.filteredModelOptions(); if (matchesKey(data, "ctrl+c")) { this.done({ type: "cancel" }); return; } if (matchesKey(data, "escape")) { this.mode = "agents"; this.query = ""; return; } if (matchesKey(data, "backspace")) { this.query = this.query.slice(0, -1); this.modelCursor = Math.min( this.modelCursor, Math.max(0, this.filteredModelOptions().length - 1), ); return; } if (matchesKey(data, "down") || matchesKey(data, "j")) { this.modelCursor = Math.min( Math.max(0, options.length - 1), this.modelCursor + 1, ); return; } if (matchesKey(data, "up") || matchesKey(data, "k")) { this.modelCursor = Math.max(0, this.modelCursor - 1); return; } if (matchesKey(data, "return")) { const selected = options[this.modelCursor]; if (!selected) return; if (selected === CUSTOM_MODEL) { this.done({ type: "custom", agent: this.selectedRow === SET_ALL_AGENTS ? "all" : this.selectedRow, config: this.draft, }); return; } if (selected === KEEP_CURRENT) { this.mode = "agents"; return; } this.applyModelSelection( selected === INHERIT_MODEL ? undefined : selected, ); this.mode = "agents"; return; } if (data.length === 1 && data.charCodeAt(0) >= 32) { this.query += data; this.modelCursor = 0; } } private applyModelSelection(model: string | undefined): void { const row = this.rows[this.cursor]; if (row === SET_ALL_AGENTS) { for (const name of this.rows.slice(1)) this.setModel(name, model); return; } if (!row) return; this.setModel(row, model); } private applyThinkingSelection(thinking: ThinkingLevel | undefined): void { const row = this.selectedRow; if (row === SET_ALL_AGENTS) { for (const name of this.rows.slice(1)) this.setThinking(name, thinking); return; } this.setThinking(row, thinking); } private applyInherit(): void { const row = this.rows[this.cursor]; if (row === SET_ALL_AGENTS) { for (const name of this.rows.slice(1)) this.clearEntry(name); return; } if (row) this.clearEntry(row); } private setModel(name: string, model: string | undefined): void { const current = this.draft[name] ?? {}; if (model === undefined) delete current.model; else current.model = model; if (!current.model && !current.thinking) delete this.draft[name]; else this.draft[name] = current; } private setThinking(name: string, thinking: ThinkingLevel | undefined): void { const current = this.draft[name] ?? {}; if (thinking === undefined) delete current.thinking; else current.thinking = thinking; if (!current.model && !current.thinking) delete this.draft[name]; else this.draft[name] = current; } private clearEntry(name: string): void { delete this.draft[name]; } private filteredModelOptions(): string[] { const query = this.query.trim().toLowerCase(); if (!query) return this.modelOptions; return this.modelOptions.filter((option) => option.toLowerCase().includes(query), ); } private renderAgentList(width: number): string[] { const lines: string[] = []; const line = (text = "") => truncateToWidth(text, Math.max(1, width), "…", true); lines.push(line("Assign Models and Effort to Agents")); lines.push(""); lines.push(line("Current assignments:")); lines.push(""); const visibleRows = Math.min(AGENT_LIST_MAX_VISIBLE_ROWS, this.rows.length); const listCursor = Math.min(this.cursor, this.rows.length - 1); const start = Math.max( 0, Math.min( listCursor - Math.floor(visibleRows / 2), Math.max(0, this.rows.length - visibleRows), ), ); const end = Math.min(this.rows.length, start + visibleRows); if (start > 0) lines.push(line(` ↑ ${start} more agent(s)`)); for (let i = start; i < end; i++) { const row = this.rows[i] ?? SET_ALL_AGENTS; const focused = i === this.cursor; const label = row === SET_ALL_AGENTS ? this.renderSetAllLabel(row) : this.renderAgentLabel(row); lines.push(line(`${focused ? "▸" : " "} ${label}`)); } if (end < this.rows.length) lines.push(line(` ↓ ${this.rows.length - end} more agent(s)`)); lines.push(""); lines.push( line(`${this.cursor === this.rows.length ? "▸" : " "} Continue`), ); lines.push( line(`${this.cursor === this.rows.length + 1 ? "▸" : " "} ← Back`), ); lines.push(""); lines.push( line( "j/k: scroll • g/G: top/bottom • enter: change model / confirm • e: effort • i: inherit • c: custom • ctrl+s: save • esc: back", ), ); return lines; } private renderModelPicker(width: number): string[] { const lines: string[] = []; const options = this.filteredModelOptions(); const line = (text = "") => truncateToWidth(text, Math.max(1, width), "…", true); lines.push(line(`Select model for ${sanitizeTerminalText(this.selectedRow)}`)); lines.push(""); lines.push(line(`◎ ${this.query || "search..."}`)); lines.push(""); const start = Math.max( 0, Math.min( this.modelCursor - Math.floor(MODEL_LIST_MAX_VISIBLE_ROWS / 2), Math.max(0, options.length - MODEL_LIST_MAX_VISIBLE_ROWS), ), ); const end = Math.min(options.length, start + MODEL_LIST_MAX_VISIBLE_ROWS); for (let i = start; i < end; i++) { const focused = i === this.modelCursor; lines.push(line(`${focused ? "▸" : " "} ${sanitizeTerminalText(options[i] ?? "")}`)); } if (options.length === 0) lines.push(line(" No matching models")); lines.push(""); lines.push( line("j/k: navigate • type: search • enter: select • esc: back"), ); return lines; } private handleEffortInput(data: string): void { if (matchesKey(data, "ctrl+c")) { this.done({ type: "cancel" }); return; } if (matchesKey(data, "escape")) { this.mode = "agents"; return; } if (matchesKey(data, "down") || matchesKey(data, "j")) { this.effortCursor = Math.min( Math.max(0, THINKING_OPTIONS.length - 1), this.effortCursor + 1, ); return; } if (matchesKey(data, "up") || matchesKey(data, "k")) { this.effortCursor = Math.max(0, this.effortCursor - 1); return; } if (!matchesKey(data, "return")) return; const selected = THINKING_OPTIONS[this.effortCursor]; if (selected === INHERIT_THINKING) this.applyThinkingSelection(undefined); else this.applyThinkingSelection(selected); this.mode = "agents"; } private renderEffortPicker(width: number): string[] { const lines: string[] = []; const line = (text = "") => truncateToWidth(text, Math.max(1, width), "…", true); lines.push(line(`Select effort for ${sanitizeTerminalText(this.selectedRow)}`)); lines.push(""); for (let i = 0; i < THINKING_OPTIONS.length; i++) { const focused = i === this.effortCursor; lines.push(line(`${focused ? "▸" : " "} ${THINKING_OPTIONS[i]}`)); } lines.push(""); lines.push(line("j/k: navigate • enter: select • esc: back")); return lines; } private renderSetAllLabel(row: string): string { const models = this.rows .slice(1) .map((name) => this.draft[name]?.model ?? "inherit"); const efforts = this.rows .slice(1) .map((name) => this.draft[name]?.thinking ?? "inherit"); const firstModel = models[0] ?? "inherit"; const firstEffort = efforts[0] ?? "inherit"; const modelLabel = models.every((value) => value === firstModel) ? firstModel : "mixed"; const effortLabel = efforts.every((value) => value === firstEffort) ? firstEffort : "mixed"; return `${sanitizeTerminalText(row).padEnd(20)} model=${sanitizeTerminalText(modelLabel)}, effort=${sanitizeTerminalText(effortLabel)}`; } private renderAgentLabel(row: string): string { const model = this.draft[row]?.model ?? "inherit"; const effort = this.draft[row]?.thinking ?? "inherit"; return `${sanitizeTerminalText(row).padEnd(20)} model=${sanitizeTerminalText(model)}, effort=${sanitizeTerminalText(effort)}`; } } async function showSddModelPanel( ctx: ExtensionContext, config: AgentModelConfig, ): Promise { const modelOptions = await getPiModelOptions(ctx); const agents = listDiscoverableAgents(ctx.cwd).map((agent) => agent.name); return ctx.ui.custom( (_tui, _theme, _keybindings, done) => new SddModelPanel(config, modelOptions, agents, done), { overlay: true, overlayOptions: { anchor: "center", width: "70%", minWidth: 72, maxHeight: "85%", }, }, ); } async function handleModelsCommand(ctx: ExtensionContext): Promise { const savedConfig = await readSavedModelConfigAsync(ctx.cwd); if (savedConfig.status === "invalid") { ctx.ui.notify( `el Gentleman cannot open model config because ${savedConfig.path} is invalid JSON or not an object. Fix or remove the file, then run /gentle:models again.`, "warning", ); return; } let config = savedConfig.status === "valid" ? savedConfig.config : {}; let result = await showSddModelPanel(ctx, config); while (result.type === "custom") { config = cloneModelConfig(result.config); const current = result.agent === "all" ? "inherit" : (config[result.agent]?.model ?? "inherit"); const custom = await ctx.ui.input( `${result.agent === "all" ? "all agents" : sanitizeTerminalText(result.agent)} custom model id`, current === "inherit" ? "provider/model" : sanitizeTerminalText(current), ); if (custom === undefined) return; const trimmed = custom.trim(); if (trimmed.length > 0) { const model = normalizeModelId(trimmed); if (!model) { ctx.ui.notify( "Custom model id must be a single-line provider/model identifier using letters, numbers, '.', '-', '_', '~', ':', '@', '/', '+', '%' only.", "warning", ); result = await showSddModelPanel(ctx, config); continue; } if (result.agent === "all") { const next: AgentModelConfig = { ...config }; for (const agent of listDiscoverableAgents(ctx.cwd)) { next[agent.name] = { ...(next[agent.name] ?? {}), model, }; } config = next; } else { config = { ...config, [result.agent]: { ...(config[result.agent] ?? {}), model, }, }; } } result = await showSddModelPanel(ctx, config); } if (result.type !== "save") return; writeModelConfig(ctx.cwd, result.config); const applyResult = await applyModelConfigAsync(ctx.cwd, result.config); ctx.ui.notify( [ "el Gentleman global model config saved.", `Global config: ${modelConfigPath(ctx.cwd)}`, `Agents updated: ${applyResult.updated}`, ...describeModelConfig(ctx.cwd, result.config), ].join("\n"), "info", ); } async function handlePersonaCommand(ctx: ExtensionContext): Promise { const current = readPersonaMode(ctx.cwd); const selected = await ctx.ui.select( `el Gentleman persona (current: ${current})`, [...PERSONA_OPTIONS], ); if (selected !== "gentleman" && selected !== "neutral") return; writePersonaMode(ctx.cwd, selected); ctx.ui.notify( [ `el Gentleman persona set to: ${selected}`, `Config: ${personaConfigPath(ctx.cwd)}`, "Run /reload or start a new Pi session for already-injected prompts to refresh.", ].join("\n"), "info", ); } export default function gentleAi(pi: ExtensionAPI): void { function runSddPreflight(ctx: ExtensionContext): Promise { return ensureSddPreflight(ctx, { pi, installAssets: (cwd) => installSddAssets(cwd, false), applyModelConfig: async () => applySavedModelConfig(ctx), }); } pi.on("session_start", async (_event, ctx) => { try { const installResult = installSddAssets(ctx.cwd, false); const modelResult = await applySavedModelConfig(ctx); if (ctx.hasUI && modelResult.invalidPath) { ctx.ui.notify( `el Gentleman skipped model config because ${modelResult.invalidPath} is invalid JSON or not an object. Fix or remove the file, then run /gentle:models again.`, "warning", ); return; } if (ctx.hasUI && modelResult.updated > 0) { ctx.ui.notify( `el Gentleman applied SDD model config to ${modelResult.updated} agent(s). Global SDD assets ready: ${installResult.agents} new agent(s), ${installResult.chains} new chain(s), ${installResult.support} new support file(s).`, "info", ); } } catch (error) { if (ctx.hasUI) { const message = error instanceof Error ? error.message : String(error); ctx.ui.notify( `el Gentleman model config sweep failed: ${message}`, "warning", ); } } }); pi.on("input", async (event, ctx) => { if (typeof event.text !== "string" || !isSddPreflightTrigger(event.text)) { return { action: "continue" }; } await runSddPreflight(ctx); return { action: "continue" }; }); pi.on("before_agent_start", async (event, ctx) => { const isSddAgent = isSddAgentStartEvent(event); const isNamedAgent = isNamedAgentStartEvent(event); if (isSddAgent && !getSddPreflightPreferences(ctx)) { await runSddPreflight(ctx); } const prefs = getSddPreflightPreferences(ctx); const sddPrompt = prefs && (!isNamedAgent || isSddAgent) ? `\n\n${renderSddPreflightPrompt(prefs)}` : ""; const gentlePrompt = isNamedAgent || isSddAgent ? "" : `\n\n${buildGentlePrompt(readPersonaMode(ctx.cwd))}`; return { systemPrompt: `${event.systemPrompt}${gentlePrompt}${sddPrompt}`, }; }); pi.on("tool_call", async (event, ctx) => { if (event.toolName !== "bash") return undefined; if (!isRecord(event.input) || typeof event.input.command !== "string") return undefined; return confirmCommand(event.input.command, ctx); }); pi.registerCommand("gentle-ai:install-sdd", { description: "Repair or refresh global Gentle AI SDD subagent and chain assets.", handler: async (args, ctx) => { const force = args.includes("--force"); const result = installSddAssets(ctx.cwd, force); ctx.ui.notify( `Global Gentle AI SDD assets installed: ${result.agents} agent(s), ${result.chains} chain(s), ${result.support} support file(s), ${result.skipped} already present.`, "info", ); }, }); pi.registerCommand("gentle-ai:sdd-preflight", { description: "Run or reuse the lazy SDD preflight for this Pi session.", handler: async (_args, ctx) => { await runSddPreflight(ctx); }, }); pi.registerCommand("gentle:sdd-preflight", { description: "Compatibility alias for /gentle-ai:sdd-preflight.", handler: async (_args, ctx) => { await runSddPreflight(ctx); }, }); pi.registerCommand("gentle:models", { description: "Configure global per-agent models for el Gentleman.", handler: async (_args, ctx) => { await handleModelsCommand(ctx); }, }); pi.registerCommand("gentle-ai:models", { description: "Compatibility alias for /gentle:models.", handler: async (_args, ctx) => { await handleModelsCommand(ctx); }, }); pi.registerCommand("gentleman:models", { description: "Compatibility alias for /gentle:models.", handler: async (_args, ctx) => { await handleModelsCommand(ctx); }, }); pi.registerCommand("gentle:persona", { description: "Switch el Gentleman persona between gentleman and neutral.", handler: async (_args, ctx) => { await handlePersonaCommand(ctx); }, }); pi.registerCommand("gentle-ai:persona", { description: "Compatibility alias for /gentle:persona.", handler: async (_args, ctx) => { await handlePersonaCommand(ctx); }, }); pi.registerCommand("gentleman:persona", { description: "Compatibility alias for /gentle:persona.", handler: async (_args, ctx) => { await handlePersonaCommand(ctx); }, }); pi.registerCommand("gentle-ai:status", { description: "Show Gentle AI package status for this project.", handler: async (_args, ctx) => { const agentsInstalled = existsSync( join(gentlePiAgentHome(), "agents", "sdd-apply.md"), ); const chainsInstalled = existsSync( join(gentlePiAgentHome(), "chains", "sdd-full.chain.md"), ); const openspecConfigured = existsSync( join(ctx.cwd, "openspec", "config.yaml"), ); const staleSddAssets = sddGlobalAssetDriftCount(); const staleLocalOverrides = sddLocalOverrideDriftCount(ctx.cwd); const modelConfig = await readModelConfigAsync(ctx.cwd); ctx.ui.notify( [ "el Gentleman package is active.", `Persona: ${readPersonaMode(ctx.cwd)}`, `Global SDD agents: ${agentsInstalled ? "installed" : "not installed"}`, `Global SDD chains: ${chainsInstalled ? "installed" : "not installed"}`, `Global SDD assets stale: ${staleSddAssets} file(s)${ staleSddAssets > 0 ? " — run /gentle-ai:install-sdd --force to refresh intentionally" : "" }`, `Project-local SDD override drift: ${staleLocalOverrides} file(s)${ staleLocalOverrides > 0 ? " — run /gentle-ai:install-sdd --force only if you intentionally want to replace local overrides" : "" }`, `OpenSpec config: ${openspecConfigured ? "present" : "missing"}`, `Global model config: ${existsSync(modelConfigPath(ctx.cwd)) ? "present" : "missing"}`, ...describeModelConfig(ctx.cwd, modelConfig), ].join("\n"), staleSddAssets > 0 || staleLocalOverrides > 0 ? "warning" : "info", ); }, }); }