import { createHash } from "node:crypto"; import * as fs from "node:fs"; import * as path from "node:path"; import { applyModelOverride } from "../config/model-resolver.js"; import type { Platform, PlatformPaths } from "../platform/types.js"; import { getProjectStatePath } from "../workspace/state-paths.js"; import type { Manifest, ManifestStatus, UiDesignSession } from "./types.js"; /** * Session-lifecycle state for `/supi:ui-design`. * * Mirrors the planning approval-flow pattern: one active session at a time, * tracked via module-level state; swapped cleanly on new invocations; cleaned * up by the `agent_end` hook when the director terminates. */ let activeSession: UiDesignSession | null = null; let activeCleanup: (() => Promise) | null = null; let lastProgressFingerprint: string | null = null; const MANIFEST_STATUSES = new Set([ "in-progress", "critiquing", "awaiting-review", "complete", "discarded", ]); const COMPLETION_PROOF_FILE = "completion-proof.json"; const REVIEW_APPROVAL_FILE = "review-approval.json"; const PATH_URI_RE = /^[a-z][a-z0-9+.-]*:\/\//i; const GLOB_META_RE = /[*?\[\]{}]/; type UiDesignReviewDecision = "approve" | "request-changes" | "discard"; interface UiDesignReviewApprovalRecord { question: string; options: string[]; selected: UiDesignReviewDecision; selectedLabel: string; recordedAt: string; } interface CompletionProof { valid: boolean; validatedAt: string; issues: string[]; page: string; critiquePath: string; reviewPath: string; approvalRecordPath: string; approvalDecision: UiDesignReviewDecision | null; critique: NonNullable; /** Set only for pencil-mcp sessions; records the absolute .pen path audited. */ penFilePath?: string; } const RESUME_STEER_TEMPLATE = (sessionDir: string): string => [ "Continue the /supi:ui-design run.", `Read \`${path.join(sessionDir, "manifest.json")}\` and \`${path.join(sessionDir, "decomposition.json")}\` for state;`, "resume the first phase whose precondition output is missing.", ].join(" "); const REPAIR_COMPLETE_STEER_TEMPLATE = (sessionDir: string, completionIssues: string[]): string => [ "Continue the /supi:ui-design run.", `The manifest at \`${path.join(sessionDir, "manifest.json")}\` claims \`status: \"complete\"\` but completion validation failed: ${completionIssues.join(", ")}.`, `Read \`${path.join(sessionDir, "manifest.json")}\`, \`${path.join(sessionDir, "page.html")}\`, \`${path.join(sessionDir, "critique.md")}\`, and \`${path.join(sessionDir, "screen-review.html")}\` if present, then resume the first incomplete review/finalization phase and rewrite the manifest truthfully.`, ].join(" "); const REPAIR_COMPLETE_STEER_TEMPLATE_PENCIL = ( sessionDir: string, penFilePath: string | undefined, completionIssues: string[], ): string => { const penInstruction = penFilePath ? `Re-open \`${penFilePath}\` via \`mcp__pencil_open_document\` and inspect` : "The manifest is missing `penFilePath` — read `manifest.json`, restore the absolute .pen path, then inspect"; return [ "Continue the /supi:ui-design run.", `The manifest at \`${path.join(sessionDir, "manifest.json")}\` claims \`status: \"complete\"\` but completion validation failed: ${completionIssues.join(", ")}.`, `${penInstruction} \`${path.join(sessionDir, "node-manifest.json")}\`, \`${path.join(sessionDir, "critique.md")}\`, and \`${path.join(sessionDir, "screen-review.png")}\` if present, then resume the first incomplete review/finalization phase and rewrite the manifest truthfully.`, ].join(" "); }; export function generateUiDesignSessionId(): string { const now = new Date(); const date = now.toISOString().slice(0, 10).replace(/-/g, ""); const time = now.toISOString().slice(11, 19).replace(/:/g, ""); const suffix = Math.random().toString(36).slice(2, 6).padEnd(4, "0"); return `uidesign-${date}-${time}-${suffix}`; } export function createSessionDir(paths: PlatformPaths, cwd: string, sessionId: string): string { const dir = getProjectStatePath(paths, cwd, "ui-design", sessionId); fs.mkdirSync(dir, { recursive: true }); return dir; } function snapshotSessionProgress(sessionDir: string): string | null { if (!fs.existsSync(sessionDir)) return null; const hash = createHash("sha256"); const visit = (currentDir: string): void => { const entries = fs.readdirSync(currentDir, { withFileTypes: true }) .sort((left, right) => left.name.localeCompare(right.name)); for (const entry of entries) { const absolutePath = path.join(currentDir, entry.name); const relativePath = path.relative(sessionDir, absolutePath); if (entry.isDirectory()) { hash.update("dir\0"); hash.update(relativePath); hash.update("\0"); visit(absolutePath); continue; } hash.update("file\0"); hash.update(relativePath); hash.update("\0"); hash.update(fs.readFileSync(absolutePath)); hash.update("\0"); } }; try { visit(sessionDir); return hash.digest("hex"); } catch { return null; } } function markSessionProgress(sessionDir: string): void { lastProgressFingerprint = snapshotSessionProgress(sessionDir); } function sessionMadeProgress(sessionDir: string): boolean { const nextFingerprint = snapshotSessionProgress(sessionDir); if (nextFingerprint === null || nextFingerprint === lastProgressFingerprint) { return false; } lastProgressFingerprint = nextFingerprint; return true; } export function startUiDesignTracking( session: UiDesignSession, cleanup: () => Promise, ): void { // Swap: run previous cleanup, never block startup on its resolution. const previousCleanup = activeCleanup; activeSession = session; activeCleanup = cleanup; markSessionProgress(session.dir); if (previousCleanup) { // Fire-and-forget: we already replaced the active references, so a // failing previous cleanup cannot leak state into the new session. previousCleanup().catch(() => {}); } } export function cancelUiDesignTracking(_reason: string): void { activeSession = null; activeCleanup = null; lastProgressFingerprint = null; } export function isUiDesignActive(): boolean { return activeSession !== null; } export function getActiveUiDesignSession(): UiDesignSession | null { return activeSession; } /** * Idempotent cleanup used from session_start / session_shutdown hooks. * Runs the active cleanup (if any) and clears tracking state. */ export async function stopActiveUiDesignSession(): Promise { await runCleanup(); activeSession = null; activeCleanup = null; lastProgressFingerprint = null; } function isStringArray(value: unknown): value is string[] { return Array.isArray(value) && value.every((item) => typeof item === "string"); } function isCritiqueSummary(value: unknown): value is NonNullable { if (!value || typeof value !== "object") return false; const record = value as Record; return ( typeof record.fixableCount === "number" && typeof record.advisoryCount === "number" && typeof record.fixIterations === "number" ); } function isManifest(value: unknown): value is Manifest { if (!value || typeof value !== "object") return false; const record = value as Record; return ( typeof record.id === "string" && typeof record.backend === "string" && typeof record.status === "string" && MANIFEST_STATUSES.has(record.status as ManifestStatus) && typeof record.acknowledged === "boolean" && typeof record.createdAt === "string" && typeof record.page === "string" && isStringArray(record.components) && isStringArray(record.sections) && (record.scope === undefined || record.scope === "page" || record.scope === "flow" || record.scope === "component") && (record.topic === undefined || typeof record.topic === "string") && (record.approvedAt === undefined || typeof record.approvedAt === "string") && (record.critique === undefined || isCritiqueSummary(record.critique)) && (record.penFilePath === undefined || typeof record.penFilePath === "string") ); } function readManifest(sessionDir: string): Manifest | null { const manifestPath = path.join(sessionDir, "manifest.json"); if (!fs.existsSync(manifestPath)) return null; try { const parsed = JSON.parse(fs.readFileSync(manifestPath, "utf-8")); return isManifest(parsed) ? parsed : null; } catch { return null; } } function resolvePathWithinDir( rootDir: string, candidatePath: string, baseDirs: string[] = [rootDir], ): string | null { if (candidatePath.trim().length === 0 || PATH_URI_RE.test(candidatePath)) { return null; } const root = path.resolve(rootDir); for (const baseDir of baseDirs) { const resolved = path.resolve(baseDir, candidatePath); const relative = path.relative(root, resolved); if (!relative.startsWith("..") && !path.isAbsolute(relative)) { return resolved; } } return null; } function resolveSessionArtifactPath(sessionDir: string, relativePath: string): string | null { return resolvePathWithinDir(sessionDir, relativePath); } function readTextFile(filePath: string): string | null { try { return fs.readFileSync(filePath, "utf-8"); } catch { return null; } } function looksLikeHtmlDocument(html: string): boolean { const normalized = html.toLowerCase(); return ( normalized.includes("") && normalized.includes("") ); } function validateHtmlArtifact(filePath: string, label: string, issues: string[]): void { const html = readTextFile(filePath); if (html === null) { issues.push(`${label} unreadable`); return; } if (!looksLikeHtmlDocument(html)) { issues.push(`${label} is not a full HTML document`); } } function normalizeReviewDecision(label: string): UiDesignReviewDecision | null { const normalized = label.trim().toLowerCase().replace(/[^a-z]+/g, ""); switch (normalized) { case "approve": case "approved": return "approve"; case "requestchanges": case "requestchange": return "request-changes"; case "discard": case "discarded": return "discard"; default: return null; } } function hasReviewDecisionSet(options: string[]): boolean { const decisions = new Set( options .map((option) => normalizeReviewDecision(option)) .filter((decision): decision is UiDesignReviewDecision => decision !== null), ); return ( decisions.has("approve") && decisions.has("request-changes") && decisions.has("discard") ); } function isReviewApprovalRecord(value: unknown): value is UiDesignReviewApprovalRecord { if (!value || typeof value !== "object") return false; const record = value as Record; return ( typeof record.question === "string" && isStringArray(record.options) && (record.selected === "approve" || record.selected === "request-changes" || record.selected === "discard") && typeof record.selectedLabel === "string" && typeof record.recordedAt === "string" ); } export function recordUiDesignReviewApproval( question: string, options: string[], selectedLabel: string, ): void { const session = activeSession; if (!session) return; const selected = normalizeReviewDecision(selectedLabel); if (!selected || !hasReviewDecisionSet(options)) return; // Require a review artifact matching the active backend. Pencil sessions // produce a PNG exported from the .pen file; local-html produces an HTML // companion page. Either satisfies the completion gate. const reviewArtifact = session.backend === "pencil-mcp" ? "screen-review.png" : "screen-review.html"; if (!fs.existsSync(path.join(session.dir, reviewArtifact))) return; const record: UiDesignReviewApprovalRecord = { question, options: [...options], selected, selectedLabel, recordedAt: new Date().toISOString(), }; try { fs.writeFileSync( path.join(session.dir, REVIEW_APPROVAL_FILE), JSON.stringify(record, null, 2), ); } catch { // Non-fatal: completion validation will surface the missing audit artifact. } } function readReviewApprovalRecord(sessionDir: string): UiDesignReviewApprovalRecord | null { const approvalPath = path.join(sessionDir, REVIEW_APPROVAL_FILE); if (!fs.existsSync(approvalPath)) return null; try { const parsed = JSON.parse(fs.readFileSync(approvalPath, "utf-8")); return isReviewApprovalRecord(parsed) ? parsed : null; } catch { return null; } } function requireSessionArtifact( sessionDir: string, relativePath: string, issues: string[], ): string | null { const resolved = resolveSessionArtifactPath(sessionDir, relativePath); if (!resolved) { issues.push(`${relativePath} escapes the session dir`); return null; } if (!fs.existsSync(resolved)) { issues.push(relativePath); return null; } return resolved; } function validateTextArtifact(filePath: string, label: string, issues: string[]): void { const text = readTextFile(filePath); if (text === null) { issues.push(`${label} unreadable`); return; } if (text.trim().length === 0) { issues.push(`${label} is empty`); } } function validateJsonArtifact(filePath: string, label: string, issues: string[]): void { const text = readTextFile(filePath); if (text === null) { issues.push(`${label} unreadable`); return; } try { JSON.parse(text); } catch { issues.push(`${label} is not valid JSON`); } } function validateSessionTextArtifact( sessionDir: string, relativePath: string, issues: string[], ): void { const filePath = requireSessionArtifact(sessionDir, relativePath, issues); if (!filePath) return; validateTextArtifact(filePath, relativePath, issues); } function validateSessionHtmlArtifact( sessionDir: string, relativePath: string, issues: string[], ): void { const filePath = requireSessionArtifact(sessionDir, relativePath, issues); if (!filePath) return; validateHtmlArtifact(filePath, relativePath, issues); } function validateSessionJsonArtifact( sessionDir: string, relativePath: string, issues: string[], ): void { const filePath = requireSessionArtifact(sessionDir, relativePath, issues); if (!filePath) return; validateJsonArtifact(filePath, relativePath, issues); } function validateTrackedComponentArtifacts( sessionDir: string, manifest: Manifest, issues: string[], ): void { for (const component of manifest.components) { validateSessionTextArtifact(sessionDir, path.join("components", `${component}.html`), issues); validateSessionJsonArtifact( sessionDir, path.join("components", `${component}.tokens.json`), issues, ); } for (const section of manifest.sections) { validateSessionTextArtifact(sessionDir, path.join("sections", `${section}.html`), issues); } } function extractMarkdownSection(markdown: string, heading: string): string | null { const lines = markdown.split(/\r?\n/); const target = `## ${heading}`.toLowerCase(); const startIndex = lines.findIndex((line) => line.trim().toLowerCase() === target); if (startIndex === -1) return null; const sectionLines: string[] = []; for (let index = startIndex + 1; index < lines.length; index++) { const line = lines[index]; if (/^##\s+/.test(line.trim())) break; sectionLines.push(line); } return sectionLines.join("\n").trim(); } function countCritiqueItems(section: string, label: string, issues: string[]): number { const lines = section .split(/\r?\n/) .map((line) => line.trim()) .filter(Boolean); if (lines.length === 0) { issues.push(`critique.md ${label} section is empty`); return 0; } if (lines.every((line) => /^(?:[-*+]\s+)?(?:none|n\/a)$/i.test(line))) { return 0; } const bulletLines = lines.filter((line) => /^[-*+]\s+/.test(line) || /^\d+\.\s+/.test(line)); if (bulletLines.length !== lines.length) { issues.push(`critique.md ${label} section must be a bullet list or 'none'`); } return bulletLines.length; } function parseCritiqueSummary(markdown: string, issues: string[]): { fixableCount: number; advisoryCount: number } { const fixableSection = extractMarkdownSection(markdown, "Fixable"); const advisorySection = extractMarkdownSection(markdown, "Advisory"); if (!fixableSection) { issues.push("critique.md missing `## Fixable` section"); } if (!advisorySection) { issues.push("critique.md missing `## Advisory` section"); } const fixableCount = fixableSection ? countCritiqueItems(fixableSection, "Fixable", issues) : 0; const advisoryCount = advisorySection ? countCritiqueItems(advisorySection, "Advisory", issues) : 0; if (fixableCount > 0) { issues.push(`critique.md lists ${fixableCount} unresolved fixable item(s)`); } return { fixableCount, advisoryCount }; } function writeCompletionProof(sessionDir: string, proof: CompletionProof): void { try { fs.writeFileSync( path.join(sessionDir, COMPLETION_PROOF_FILE), JSON.stringify(proof, null, 2), ); } catch { // non-fatal: validation still gates completion in-memory } } function sameCritiqueSummary( left: Manifest["critique"] | undefined, right: Manifest["critique"] | undefined, ): boolean { if (!left && !right) return true; if (!left || !right) return false; return ( left.fixableCount === right.fixableCount && left.advisoryCount === right.advisoryCount && left.fixIterations === right.fixIterations ); } interface NodeManifest { pageNodeId: string; sectionNodeIds: string[]; componentNodeIds: string[]; } function isNodeManifest(value: unknown): value is NodeManifest { if (!value || typeof value !== "object") return false; const record = value as Record; if (typeof record.pageNodeId !== "string" || record.pageNodeId.length === 0) return false; if (!isStringArray(record.sectionNodeIds)) return false; if (!isStringArray(record.componentNodeIds)) return false; return ( record.sectionNodeIds.every((id) => typeof id === "string" && id.length > 0) && record.componentNodeIds.every((id) => typeof id === "string" && id.length > 0) ); } function validateCritiqueFromFile( critiquePath: string, manifestCritique: Manifest["critique"], issues: string[], ): { fixableCount: number; advisoryCount: number } { let critique = { fixableCount: manifestCritique?.fixableCount ?? 0, advisoryCount: manifestCritique?.advisoryCount ?? 0, }; if (!fs.existsSync(critiquePath)) { issues.push("critique.md"); return critique; } const critiqueContent = readTextFile(critiquePath); if (critiqueContent === null) { issues.push("critique.md unreadable"); return critique; } critique = parseCritiqueSummary(critiqueContent, issues); return critique; } function validateLocalHtmlCompletion( sessionDir: string, manifest: Manifest, issues: string[], ): { fixableCount: number; advisoryCount: number; approval: UiDesignReviewApprovalRecord | null } { validateSessionTextArtifact(sessionDir, "context.md", issues); validateSessionHtmlArtifact(sessionDir, "screen-decomposition.html", issues); validateSessionJsonArtifact(sessionDir, "decomposition.json", issues); const pagePath = resolveSessionArtifactPath(sessionDir, manifest.page); if (!pagePath) { issues.push(`${manifest.page || "page.html"} escapes the session dir`); } else if (!fs.existsSync(pagePath)) { issues.push(manifest.page || "page.html"); } else { validateHtmlArtifact(pagePath, manifest.page, issues); } validateTrackedComponentArtifacts(sessionDir, manifest, issues); const critique = validateCritiqueFromFile( path.join(sessionDir, "critique.md"), manifest.critique, issues, ); validateSessionHtmlArtifact(sessionDir, "screen-review.html", issues); const approval = readReviewApprovalRecord(sessionDir); if (!approval) { issues.push(REVIEW_APPROVAL_FILE); } else if (approval.selected !== "approve") { issues.push(`${REVIEW_APPROVAL_FILE} selected ${approval.selected}`); } return { ...critique, approval }; } function validatePencilMcpCompletion( sessionDir: string, manifest: Manifest, issues: string[], ): { fixableCount: number; advisoryCount: number; approval: UiDesignReviewApprovalRecord | null } { validateSessionTextArtifact(sessionDir, "context.md", issues); validateSessionJsonArtifact(sessionDir, "decomposition.json", issues); // penFilePath may live outside sessionDir; validate the raw absolute path. if (!manifest.penFilePath || typeof manifest.penFilePath !== "string") { issues.push("manifest.penFilePath missing"); } else if (!fs.existsSync(manifest.penFilePath)) { issues.push(`penFilePath missing on disk: ${manifest.penFilePath}`); } const nodeManifestPath = requireSessionArtifact(sessionDir, "node-manifest.json", issues); if (nodeManifestPath) { const raw = readTextFile(nodeManifestPath); if (raw === null) { issues.push("node-manifest.json unreadable"); } else { try { const parsed = JSON.parse(raw); if (!isNodeManifest(parsed)) { issues.push("node-manifest.json is malformed (expected pageNodeId + sectionNodeIds + componentNodeIds)"); } } catch { issues.push("node-manifest.json is not valid JSON"); } } } const critique = validateCritiqueFromFile( path.join(sessionDir, "critique.md"), manifest.critique, issues, ); const screenReviewPath = requireSessionArtifact(sessionDir, "screen-review.png", issues); // requireSessionArtifact already pushes the missing-artifact issue; no further check. void screenReviewPath; const approval = readReviewApprovalRecord(sessionDir); if (!approval) { issues.push(REVIEW_APPROVAL_FILE); } else if (approval.selected !== "approve") { issues.push(`${REVIEW_APPROVAL_FILE} selected ${approval.selected}`); } return { ...critique, approval }; } function validateCompletionProof( sessionDir: string, manifest: Manifest, ): { issues: string[]; validatedManifest: Manifest } { const issues: string[] = []; const result = manifest.backend === "pencil-mcp" ? validatePencilMcpCompletion(sessionDir, manifest, issues) : validateLocalHtmlCompletion(sessionDir, manifest, issues); const { approval } = result; const critiqueSummary = { fixableCount: result.fixableCount, advisoryCount: result.advisoryCount, fixIterations: manifest.critique?.fixIterations ?? 0, }; const validatedManifest: Manifest = { ...manifest, approvedAt: approval?.selected === "approve" ? approval.recordedAt : manifest.approvedAt, critique: critiqueSummary, }; const isPencil = manifest.backend === "pencil-mcp"; writeCompletionProof(sessionDir, { valid: issues.length === 0, validatedAt: new Date().toISOString(), issues: [...issues], page: isPencil ? "" : manifest.page, critiquePath: "critique.md", reviewPath: isPencil ? "screen-review.png" : "screen-review.html", approvalRecordPath: REVIEW_APPROVAL_FILE, approvalDecision: approval?.selected ?? null, critique: critiqueSummary, ...(isPencil && manifest.penFilePath ? { penFilePath: manifest.penFilePath } : {}), }); return { issues, validatedManifest }; } function writeManifest(sessionDir: string, manifest: Manifest): void { fs.writeFileSync( path.join(sessionDir, "manifest.json"), JSON.stringify(manifest, null, 2), ); } async function runCleanup(): Promise { const cleanup = activeCleanup; if (!cleanup) return; try { await cleanup(); } catch { // swallow — never block agent_end } } async function resumeSession( platform: Platform, ctx: any, session: UiDesignSession, steerMessage: string, ): Promise { markSessionProgress(session.dir); if (session.resolvedModel) { await applyModelOverride(platform, ctx, "ui-design", session.resolvedModel); } platform.sendMessage( { customType: "supi-ui-design-resume", content: [{ type: "text", text: steerMessage }], display: "none", }, { deliverAs: "steer", triggerTurn: true }, ); } function discardSessionDir(sessionDir: string): void { try { fs.rmSync(sessionDir, { recursive: true, force: true }); } catch { // ignore — user can clean up manually } } function buildCompletionRepairSteer( sessionDir: string, manifest: Manifest, completionIssues: string[], ): string { return manifest.backend === "pencil-mcp" ? REPAIR_COMPLETE_STEER_TEMPLATE_PENCIL(sessionDir, manifest.penFilePath, completionIssues) : REPAIR_COMPLETE_STEER_TEMPLATE(sessionDir, completionIssues); } function sendNoUiPauseMessage( platform: Platform, sessionDir: string, status: ManifestStatus, ): void { const message = [ `ui-design session paused with status \`${status}\` in a no-UI runtime.`, `Artifacts were preserved at \`${sessionDir}\`.`, "Interactive review is unavailable here. Continue manually by inspecting that directory or rerun `/supi:ui-design` in an interactive TUI.", ].join("\n"); platform.sendMessage( { customType: "supi-ui-design-paused-no-ui", content: [{ type: "text", text: message }], display: true, }, { deliverAs: "steer", triggerTurn: false }, ); } async function handleNoUiUiDesignAgentEnd( platform: Platform, ctx: any, session: UiDesignSession, manifest: Manifest | null, ): Promise { const sessionDir = session.dir; if (!manifest) { await runCleanup(); ctx?.ui?.notify?.( `ui-design session state is unreadable; artifacts preserved at ${sessionDir}.`, "warning", ); cancelUiDesignTracking("manifest_missing_no_ui"); return; } if (manifest.status === "complete") { const completion = validateCompletionProof(sessionDir, manifest); const validatedManifest = completion.validatedManifest; if ( !sameCritiqueSummary(manifest.critique, validatedManifest.critique) || manifest.approvedAt !== validatedManifest.approvedAt ) { writeManifest(sessionDir, validatedManifest); } if (completion.issues.length > 0) { await resumeSession( platform, ctx, session, buildCompletionRepairSteer(sessionDir, manifest, completion.issues), ); return; } writeManifest(sessionDir, { ...validatedManifest, acknowledged: true }); await runCleanup(); ctx?.ui?.notify?.(`ui-design complete; artifacts kept at ${sessionDir}.`, "info"); cancelUiDesignTracking("complete_no_ui"); return; } if (manifest.status === "discarded") { await runCleanup(); discardSessionDir(sessionDir); cancelUiDesignTracking("discarded_no_ui"); return; } if ( manifest.status === "in-progress" || manifest.status === "critiquing" || manifest.status === "awaiting-review" ) { if (sessionMadeProgress(sessionDir)) { await resumeSession(platform, ctx, session, RESUME_STEER_TEMPLATE(sessionDir)); return; } sendNoUiPauseMessage(platform, sessionDir, manifest.status); ctx?.ui?.notify?.(`ui-design paused; artifacts preserved at ${sessionDir}.`, "warning"); await runCleanup(); cancelUiDesignTracking("paused_no_ui"); } } function getUiDesignWritePaths(toolName: string, input: Record): string[] | undefined { switch (toolName) { case "write": return [typeof input.path === "string" ? input.path : ""]; case "ast_edit": { const arr = Array.isArray(input.paths) ? (input.paths as unknown[]).filter((p): p is string => typeof p === "string") : []; return arr.length === 0 ? [""] : arr; } case "edit": { const edits = Array.isArray(input.edits) ? input.edits : []; if (edits.length === 0) return [""]; return edits.map((edit) => edit && typeof edit === "object" && !Array.isArray(edit) && typeof (edit as { path?: unknown }).path === "string" ? (edit as { path: string }).path : "", ); } default: return undefined; } } export function registerUiDesignToolGuard(platform: Platform): void { platform.on("tool_call", (event: any) => { const session = activeSession; if (!session) return; if (event.toolName === "resolve" && isUiDesignPlanApprovalResolveInput(event.input)) { return { block: true, reason: "UI-design mode: completion is driven by the agent_end approval hook; do not call `resolve` with `extra.title`.", }; } if (event.toolName === "bash") { return { block: true, reason: `UI-design mode: bash is not allowed. Write artifacts with write/edit inside \`${session.dir}\` and use task for delegated work.`, }; } const candidatePaths = getUiDesignWritePaths( event.toolName, (event.input ?? {}) as Record, ); if (candidatePaths === undefined) return; for (const candidatePath of candidatePaths) { if (candidatePath.trim().length === 0) { return { block: true, reason: `UI-design mode: cannot verify ${event.toolName} without a path under \`${session.dir}\`.`, }; } if (GLOB_META_RE.test(candidatePath)) { return { block: true, reason: `UI-design mode: ${event.toolName} cannot use glob pattern \`${candidatePath}\`; use a literal path under \`${session.dir}\`.`, }; } if (!resolvePathWithinDir(session.dir, candidatePath, [session.dir, process.cwd()])) { return { block: true, reason: `UI-design mode: ${event.toolName} may only write inside \`${session.dir}\`.`, }; } } }); } function isUiDesignPlanApprovalResolveInput(input: unknown): boolean { if (input === null || typeof input !== "object" || Array.isArray(input)) return false; const candidate = input as { action?: unknown; extra?: unknown }; if (candidate.action !== "apply") return false; const extra = candidate.extra; return extra !== null && typeof extra === "object" && !Array.isArray(extra) && typeof (extra as { title?: unknown }).title === "string"; } /** * Register the `agent_end` hook that drives the ui-design approval UI. * * Terminal statuses (`complete`, `discarded`, missing-manifest) tear down the * companion and cancel tracking. Resume statuses (`in-progress`, `critiquing`, * `awaiting-review`) offer the user a choice: resume the session or discard. * On resume we send a steer message and keep tracking active. */ export function registerUiDesignApprovalHook(platform: Platform): void { platform.on("agent_end", async (_event: any, ctx: any) => { const session = activeSession; if (!session) return; const sessionDir = session.dir; const manifest = readManifest(sessionDir); if (!ctx?.hasUI) { await handleNoUiUiDesignAgentEnd(platform, ctx, session, manifest); return; } // Missing / unparseable manifest — unsafe to resume if (!manifest) { const choice = await ctx.ui.select( "ui-design session is in an unknown state — what next?", ["Discard session"], ); if (choice) { await runCleanup(); discardSessionDir(sessionDir); } cancelUiDesignTracking("manifest_missing"); return; } if (manifest.status === "complete") { const completion = validateCompletionProof(sessionDir, manifest); const validatedManifest = completion.validatedManifest; if ( !sameCritiqueSummary(manifest.critique, validatedManifest.critique) || manifest.approvedAt !== validatedManifest.approvedAt ) { writeManifest(sessionDir, validatedManifest); } if (completion.issues.length > 0) { const choice = await ctx.ui.select( `ui-design session claims completion but validation failed (${completion.issues.join(", ")}) — what next?`, ["Resume session", "Discard session"], ); if (choice === "Resume session") { // Pencil manifests always cite pencil artifacts in their repair steer, // even when `penFilePath` is missing — the HTML template would point // at files pencil sessions never produce. const repairSteer = buildCompletionRepairSteer(sessionDir, manifest, completion.issues); await resumeSession( platform, ctx, session, repairSteer, ); return; } if (choice === "Discard session") { await runCleanup(); discardSessionDir(sessionDir); cancelUiDesignTracking("invalid_complete_discarded"); return; } return; } if (validatedManifest.acknowledged) return; const choice = await ctx.ui.select( "Design complete — what next?", ["Keep artifacts and exit", "Open session dir", "Discard session"], ); if (choice === "Keep artifacts and exit") { writeManifest(sessionDir, { ...validatedManifest, acknowledged: true }); await runCleanup(); } else if (choice === "Discard session") { await runCleanup(); discardSessionDir(sessionDir); } else if (choice === "Open session dir") { writeManifest(sessionDir, { ...validatedManifest, acknowledged: true }); await runCleanup(); try { await platform.exec("open", [sessionDir]); } catch { // non-fatal } } cancelUiDesignTracking("complete"); return; } if (manifest.status === "discarded") { await runCleanup(); discardSessionDir(sessionDir); cancelUiDesignTracking("discarded"); return; } // Auto-continue while the director is still producing new artifacts. Only // interrupt the user once a turn ends without any session-local progress. if ( manifest.status === "in-progress" || manifest.status === "critiquing" || manifest.status === "awaiting-review" ) { if (sessionMadeProgress(sessionDir)) { await resumeSession(platform, ctx, session, RESUME_STEER_TEMPLATE(sessionDir)); return; } const choice = await ctx.ui.select( `ui-design session paused (${manifest.status}) — what next?`, ["Resume session", "Discard session"], ); if (choice === "Resume session") { await resumeSession(platform, ctx, session, RESUME_STEER_TEMPLATE(sessionDir)); return; } if (choice === "Discard session") { await runCleanup(); discardSessionDir(sessionDir); cancelUiDesignTracking("user_discard"); return; } // Cancelled prompt — leave state as-is return; } }); }