From 3be61a8615cf3b2808171d65aa515fc6fa8b4dcd Mon Sep 17 00:00:00 2001 From: "Vincent (Wen Yu) Ge" Date: Mon, 21 Sep 2026 21:13:33 -0400 Subject: [PATCH 01/13] refactor(errors): move WizardError next to the error codes The agent builds WizardError for every decided failure, and it had to import the process-exit module to do so. The class now lives in src/lib/errors/wizard-error.ts; wizard-abort re-exports it so every other importer is unchanged. Generated-By: PostHog Desktop Task-Id: d14e92bb-6ee1-49b5-8502-39cb80079589 --- src/lib/agent/agent-interface.ts | 3 +-- src/lib/agent/runner/sequence/linear.ts | 3 +-- .../orchestrator/orchestrator-runner.ts | 3 +-- src/lib/agent/runner/shared/errors.ts | 2 +- src/lib/errors/index.ts | 1 + src/lib/errors/wizard-error.ts | 21 +++++++++++++++++++ src/lib/gateway-session.ts | 3 +-- src/utils/wizard-abort.ts | 21 +++++-------------- 8 files changed, 32 insertions(+), 25 deletions(-) create mode 100644 src/lib/errors/wizard-error.ts diff --git a/src/lib/agent/agent-interface.ts b/src/lib/agent/agent-interface.ts index 602923329..45bd05705 100644 --- a/src/lib/agent/agent-interface.ts +++ b/src/lib/agent/agent-interface.ts @@ -30,7 +30,6 @@ import { type AdditionalFeature, ADDITIONAL_FEATURE_PROMPTS, } from '@lib/wizard-session'; -import { WizardError } from '@utils/wizard-abort'; import type { AgentFailure } from './runner/shared/types'; import { createCustomHeaders } from '@utils/custom-headers'; import type { HostResolution } from '@lib/host-resolution'; @@ -58,7 +57,7 @@ import { REMARK_INSTRUCTION, RESUME_INSTRUCTION, } from './signals'; -import { classifyAuthFailure } from '@lib/errors'; +import { classifyAuthFailure, WizardError } from '@lib/errors'; import { isGrantRevoked } from '@lib/auth-session-state'; import { AgentOutputSignals } from './output-signals'; diff --git a/src/lib/agent/runner/sequence/linear.ts b/src/lib/agent/runner/sequence/linear.ts index cbdc169dc..479330533 100644 --- a/src/lib/agent/runner/sequence/linear.ts +++ b/src/lib/agent/runner/sequence/linear.ts @@ -14,8 +14,7 @@ import { OutroKind, type OutroData } from '@lib/wizard-session'; import { AgentErrorType, AgentSignals } from '../../agent-interface'; import { logToFile } from '../../../../utils/debug'; import { createBenchmarkPipeline } from '../../../middleware/benchmark'; -import { WizardError } from '../../../../utils/wizard-abort'; -import { ErrorCodes, AGENT_ERROR_CODE } from '@lib/errors'; +import { AGENT_ERROR_CODE, ErrorCodes, WizardError } from '@lib/errors'; import { analytics } from '../../../../utils/analytics'; import { formatYaraAbortMessage } from '../../../yara-hooks'; import { installSkillById } from '../../../wizard-tools'; diff --git a/src/lib/agent/runner/sequence/orchestrator/orchestrator-runner.ts b/src/lib/agent/runner/sequence/orchestrator/orchestrator-runner.ts index 43c41c137..86ac138cd 100644 --- a/src/lib/agent/runner/sequence/orchestrator/orchestrator-runner.ts +++ b/src/lib/agent/runner/sequence/orchestrator/orchestrator-runner.ts @@ -33,8 +33,7 @@ import { analytics } from '@utils/analytics'; import { ciExcludedTaskTypes } from '@utils/ci-flag-overrides'; import { logToFile } from '@utils/debug'; import { ringTerminalBell } from '@utils/terminal-bell'; -import { WizardError } from '@utils/wizard-abort'; -import { ErrorCodes } from '@lib/errors'; +import { ErrorCodes, WizardError } from '@lib/errors'; import type { AgentInteraction } from '@lib/agent/progress'; import type { AgentFailure, diff --git a/src/lib/agent/runner/shared/errors.ts b/src/lib/agent/runner/shared/errors.ts index 8578d2a0c..d991e4242 100644 --- a/src/lib/agent/runner/shared/errors.ts +++ b/src/lib/agent/runner/shared/errors.ts @@ -4,7 +4,7 @@ import type { InstallSkillResult } from '@lib/wizard-tools'; import { skillErrorCode } from '@lib/errors'; -import { WizardError } from '@utils/wizard-abort'; +import { WizardError } from '@lib/errors'; import { RunOutcome, type AgentFailure, type SequenceResult } from './types'; export const failed = (failure: AgentFailure): SequenceResult => ({ diff --git a/src/lib/errors/index.ts b/src/lib/errors/index.ts index 5558c7942..51a0793d7 100644 --- a/src/lib/errors/index.ts +++ b/src/lib/errors/index.ts @@ -5,6 +5,7 @@ export { type ErrorCode, } from './codes'; export { ERROR_CATALOG } from './catalog'; +export { WizardError } from './wizard-error'; export type { ErrorCatalogEntry, ErrorGroup, RetryAdvice } from './types'; export { classifyAuthFailure, type AuthFailureInput } from './auth'; export { AGENT_ERROR_CODE } from './agent-map'; diff --git a/src/lib/errors/wizard-error.ts b/src/lib/errors/wizard-error.ts new file mode 100644 index 000000000..65537f2ef --- /dev/null +++ b/src/lib/errors/wizard-error.ts @@ -0,0 +1,21 @@ +import type { ErrorCode } from './codes'; + +/** + * Structured error data for analytics and the machine-readable error line. + * + * A data carrier: the agent returns it inside a failure and the legacy adapter + * hands it to `wizardAbort()`, which captures it. Never thrown by the wizard. + */ +export class WizardError extends Error { + readonly code?: ErrorCode; + + constructor( + message: string, + public readonly context?: Record, + code?: ErrorCode, + ) { + super(message); + this.name = 'WizardError'; + this.code = code; + } +} diff --git a/src/lib/gateway-session.ts b/src/lib/gateway-session.ts index 577753df2..55d50d651 100644 --- a/src/lib/gateway-session.ts +++ b/src/lib/gateway-session.ts @@ -9,8 +9,7 @@ import { readFileSync } from 'node:fs'; import { logToFile } from '@utils/debug'; import { analytics } from '@utils/analytics'; -import { WizardError } from '@utils/wizard-abort'; -import { ErrorCodes } from '@lib/errors'; +import { ErrorCodes, WizardError } from '@lib/errors'; import type { HostResolution } from '@lib/host-resolution'; import { checkLlmGatewayHealth } from '@lib/health-checks/endpoints'; import { ServiceHealthStatus } from '@lib/health-checks/types'; diff --git a/src/utils/wizard-abort.ts b/src/utils/wizard-abort.ts index 7805edcc4..f5973a745 100644 --- a/src/utils/wizard-abort.ts +++ b/src/utils/wizard-abort.ts @@ -3,7 +3,7 @@ * * Sequence: cleanup -> error capture (optional) -> analytics shutdown -> outro -> process.exit * - * WizardError is a data carrier passed to wizardAbort() for analytics context, never thrown. + * WizardError (from `@lib/errors`) is a data carrier passed to wizardAbort() for analytics context, never thrown. * The legacy abort() in setup-utils.ts delegates here. */ import { analytics } from './analytics'; @@ -12,21 +12,10 @@ import { getUI } from '@ui'; import { LoggingUI } from '@ui/logging-ui'; import { OutroKind, type OutroData } from '@lib/wizard-session'; import type { ErrorCode } from '@lib/errors'; -import { emitWizardError, sanitizeErrorDetail } from '@lib/errors'; - -export class WizardError extends Error { - readonly code?: ErrorCode; - - constructor( - message: string, - public readonly context?: Record, - code?: ErrorCode, - ) { - super(message); - this.name = 'WizardError'; - this.code = code; - } -} +import { WizardError, emitWizardError, sanitizeErrorDetail } from '@lib/errors'; + +// Still importable from here; the class lives with the error codes. +export { WizardError }; interface WizardAbortOptions { message?: string; From 9ce789977408e7636c1b63243bcc3d87bdc5785d Mon Sep 17 00:00:00 2001 From: "Vincent (Wen Yu) Ge" Date: Mon, 21 Sep 2026 21:14:59 -0400 Subject: [PATCH 02/13] refactor(agent): own the progress payload types TokenUsageDelta, SpinnerHandle and AuthErrorDetail are what the agent puts on its progress events, and AgentChunk is what the streaming prompt runner yields. They move next to the code that produces them; wizard-ui.ts and the MCP prompts service re-export them so every UI importer keeps its path. The agent no longer imports from src/ui for these. Generated-By: PostHog Desktop Task-Id: d14e92bb-6ee1-49b5-8502-39cb80079589 --- src/lib/agent/mcp-prompt-streaming.ts | 18 +++++- src/lib/agent/progress.ts | 62 +++++++++++++++++-- src/lib/middleware/benchmark.ts | 3 +- src/lib/middleware/benchmarks/summary.ts | 3 +- src/lib/middleware/types.ts | 2 +- .../mcp-suggested-prompts-services.ts | 20 ++---- src/ui/wizard-ui.ts | 61 +++--------------- 7 files changed, 89 insertions(+), 80 deletions(-) diff --git a/src/lib/agent/mcp-prompt-streaming.ts b/src/lib/agent/mcp-prompt-streaming.ts index 1b9e55182..62bc48e48 100644 --- a/src/lib/agent/mcp-prompt-streaming.ts +++ b/src/lib/agent/mcp-prompt-streaming.ts @@ -12,7 +12,6 @@ * `for await (...)` and render as they arrive. */ -import type { AgentChunk } from '@ui/tui/services/mcp-suggested-prompts-services'; import type { Credentials } from '@lib/wizard-session'; import { DEFAULT_AGENT_MODEL, WIZARD_USER_AGENT } from '@lib/constants'; import { logToFile } from '@utils/debug'; @@ -22,6 +21,23 @@ import { sanitizeAgentSubprocessEnv } from '@lib/agent/agent-env-isolation'; import { createIsolatedAgentConfigDir } from '@lib/agent/stored-login'; import { analytics } from '@utils/analytics'; +/** + * Discriminated union covering every kind of streamed event the screen + * needs to render. Production yields these from Claude SDK messages; + * the playground yields them from canned scripts. + */ +export type AgentChunk = + | { kind: 'text'; text: string } + /** `command` carries CLI mode's exec command string (`call …`) so the + * screen can recover the inner tool for context-aware follow-ups. */ + | { kind: 'tool-call'; toolName: string; detail: string; command?: string } + | { kind: 'tool-result'; toolName: string; detail: string } + | { kind: 'error'; text: string } + /** Stream completed. `sessionId` is the SDK session ID of the just- + * completed turn; pass it back as `resumeSessionId` on a follow-up + * call to continue the conversation with full history. */ + | { kind: 'done'; sessionId?: string }; + // Cached SDK module — first call pays the dynamic-import cost; later // calls reuse the same module. // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/src/lib/agent/progress.ts b/src/lib/agent/progress.ts index 8be0d52f2..21550a1d6 100644 --- a/src/lib/agent/progress.ts +++ b/src/lib/agent/progress.ts @@ -15,13 +15,63 @@ import type { PendingQuestion, TaskNotice, } from '@lib/wizard-session'; -import type { - AuthErrorDetail, - SpinnerHandle, - TokenUsageDelta, -} from '@ui/wizard-ui'; +import type { SettingsConflict } from './claude-settings'; + +/** + * One assistant turn's token usage, for the hidden Ctrl+T token/cost HUD. + * `model` is the model that produced *this* turn (e.g. the SDK's + * `message.message.model`) — a subagent can run on a different model than + * the main session, and some programs override to Haiku, so pricing must key + * off the per-turn model rather than a single run-wide assumption. Omit only + * when the caller genuinely has no model context (falls back to Sonnet + * pricing — see `pricePerMtokForModel` in `@lib/agent/token-pricing`). + */ +export interface TokenUsageDelta { + inputTokens: number; + outputTokens: number; + cacheReadTokens: number; + cacheCreationTokens: number; + cacheCreation5m: number; + cacheCreation1h: number; + model?: string; +} + +/** The run spinner as the agent drives it: `WizardUI.spinner()` returns one. */ +export interface SpinnerHandle { + start(message?: string): void; + stop(message?: string): void; + message(msg?: string): void; +} -export type { AuthErrorDetail, SpinnerHandle, TokenUsageDelta }; +/** + * Context the agent attaches to a 401 so the host can pick the right copy. + * + * `hasSettingsConflict` is true when a Claude Code settings file (project, + * project-local, the user's global config, or managed) actually overrides the + * LLM Gateway auth. `conflicts` carries the exact files and keys so the screen + * can name them. When there is no conflict, the 401 has a different cause (bad + * PAT prefix, missing scope, expired key, region mismatch) and we should not + * advise the user to log out of Claude Code. + */ +export interface AuthErrorDetail { + hasSettingsConflict: boolean; + conflicts?: SettingsConflict[]; + /** + * True when the agent SDK authenticated from a stored Claude login + * (`apiKeySource: "/login managed key"`) instead of the wizard's gateway + * token — conflicting Anthropic credentials. Takes priority in the screen. + */ + usingManagedLogin?: boolean; + /** Human-readable places a conflicting Anthropic credential may live. */ + credentialPlaces?: string[]; + /** + * True when a pre-run refresh already failed on a dead grant. The login is + * gone and re-running is the only fix, so this outranks every other branch — + * none of the usual advice (key type, scopes, region) applies. + */ + sessionExpired?: boolean; + logFilePath: string; +} /** One task as the host renders it. The same shape `WizardUI.syncTodos` takes. */ export interface TaskSnapshot { diff --git a/src/lib/middleware/benchmark.ts b/src/lib/middleware/benchmark.ts index 20017ad75..bc9fc3e7b 100644 --- a/src/lib/middleware/benchmark.ts +++ b/src/lib/middleware/benchmark.ts @@ -7,7 +7,8 @@ * pipeline.finalize(resultMessage, durationMs); */ -import { getUI, type SpinnerHandle } from '@ui'; +import { getUI } from '@ui'; +import type { SpinnerHandle } from '@lib/agent/progress'; import { logToFile, getLogFilePath, configureLogFile } from '@utils/debug'; import { MiddlewarePipeline } from './pipeline'; import { PhaseDetector } from './phase-detector'; diff --git a/src/lib/middleware/benchmarks/summary.ts b/src/lib/middleware/benchmarks/summary.ts index 45c3b0a8e..1900f9cc5 100644 --- a/src/lib/middleware/benchmarks/summary.ts +++ b/src/lib/middleware/benchmarks/summary.ts @@ -1,4 +1,5 @@ -import { getUI, type SpinnerHandle } from '@ui'; +import { getUI } from '@ui'; +import type { SpinnerHandle } from '@lib/agent/progress'; import { AgentSignals } from '@lib/agent/agent-interface'; import type { Middleware, diff --git a/src/lib/middleware/types.ts b/src/lib/middleware/types.ts index ea1715700..9e84580e0 100644 --- a/src/lib/middleware/types.ts +++ b/src/lib/middleware/types.ts @@ -5,7 +5,7 @@ * and can publish data to a shared store for downstream middleware to read. */ -import type { SpinnerHandle } from '@ui'; +import type { SpinnerHandle } from '@lib/agent/progress'; export type SDKMessage = any; diff --git a/src/ui/tui/services/mcp-suggested-prompts-services.ts b/src/ui/tui/services/mcp-suggested-prompts-services.ts index 8b41505ca..484a882a8 100644 --- a/src/ui/tui/services/mcp-suggested-prompts-services.ts +++ b/src/ui/tui/services/mcp-suggested-prompts-services.ts @@ -21,22 +21,10 @@ import { } from '@lib/mcp-project-profile'; import { seedDemoEvents as runSeed } from '@lib/mcp-seed-events'; -/** - * Discriminated union covering every kind of streamed event the screen - * needs to render. Production yields these from Claude SDK messages; - * the playground yields them from canned scripts. - */ -export type AgentChunk = - | { kind: 'text'; text: string } - /** `command` carries CLI mode's exec command string (`call …`) so the - * screen can recover the inner tool for context-aware follow-ups. */ - | { kind: 'tool-call'; toolName: string; detail: string; command?: string } - | { kind: 'tool-result'; toolName: string; detail: string } - | { kind: 'error'; text: string } - /** Stream completed. `sessionId` is the SDK session ID of the just- - * completed turn; pass it back as `resumeSessionId` on a follow-up - * call to continue the conversation with full history. */ - | { kind: 'done'; sessionId?: string }; +// The streamed event shape is the agent's; re-exported so the screen and the +// playground keep their import path. +import type { AgentChunk } from '@lib/agent/mcp-prompt-streaming'; +export type { AgentChunk }; export interface McpSuggestedPromptsServices { /** diff --git a/src/ui/wizard-ui.ts b/src/ui/wizard-ui.ts index 38ad29235..17f2792f3 100644 --- a/src/ui/wizard-ui.ts +++ b/src/ui/wizard-ui.ts @@ -29,60 +29,13 @@ export function isTaskStatus(value: string): value is TaskStatus { return (Object.values(TaskStatus) as string[]).includes(value); } -/** - * One assistant turn's token usage, for the hidden Ctrl+T token/cost HUD. - * `model` is the model that produced *this* turn (e.g. the SDK's - * `message.message.model`) — a subagent can run on a different model than - * the main session, and some programs override to Haiku, so pricing must key - * off the per-turn model rather than a single run-wide assumption. Omit only - * when the caller genuinely has no model context (falls back to Sonnet - * pricing — see `pricePerMtokForModel` in `@lib/agent/token-pricing`). - */ -export interface TokenUsageDelta { - inputTokens: number; - outputTokens: number; - cacheReadTokens: number; - cacheCreationTokens: number; - cacheCreation5m: number; - cacheCreation1h: number; - model?: string; -} - -export interface SpinnerHandle { - start(message?: string): void; - stop(message?: string): void; - message(msg?: string): void; -} - -/** - * Context passed to `showAuthError` so the screen can pick the right copy. - * - * `hasSettingsConflict` is true when a Claude Code settings file (project, - * project-local, the user's global config, or managed) actually overrides the - * LLM Gateway auth. `conflicts` carries the exact files and keys so the screen - * can name them. When there is no conflict, the 401 has a different cause (bad - * PAT prefix, missing scope, expired key, region mismatch) and we should not - * advise the user to log out of Claude Code. - */ -export interface AuthErrorDetail { - hasSettingsConflict: boolean; - conflicts?: SettingsConflict[]; - /** - * True when the agent SDK authenticated from a stored Claude login - * (`apiKeySource: "/login managed key"`) instead of the wizard's gateway - * token — conflicting Anthropic credentials. Takes priority in the screen. - */ - usingManagedLogin?: boolean; - /** Human-readable places a conflicting Anthropic credential may live. */ - credentialPlaces?: string[]; - /** - * True when a pre-run refresh already failed on a dead grant. The login is - * gone and re-running is the only fix, so this outranks every other branch — - * none of the usual advice (key type, scopes, region) applies. - */ - sessionExpired?: boolean; - logFilePath: string; -} +// Progress payloads are the agent's contract; re-exported so UI code keeps its import path. +import type { + AuthErrorDetail, + SpinnerHandle, + TokenUsageDelta, +} from '@lib/agent/progress'; +export type { AuthErrorDetail, SpinnerHandle, TokenUsageDelta }; export interface WizardUI { // ── Lifecycle messages ──────────────────────────────────────────── From f15dc7a46af6957af6c2656d96bcb8085a439985 Mon Sep 17 00:00:00 2001 From: "Vincent (Wen Yu) Ge" Date: Mon, 21 Sep 2026 21:18:27 -0400 Subject: [PATCH 03/13] refactor(agent): own the outro, question and task-notice shapes OutroKind, OutroData, AskQuestion, AskAnswers, PendingQuestion and TaskNotice are what runAgent returns and asks with, so they move into src/lib/agent/progress.ts. Credentials moves next to the API types, AdditionalFeature next to the other program enums in constants, and the session's CloudRegion copy points at the one in @utils/types. wizard-session.ts re-exports all of them, so its 127 importers are unchanged. The agent no longer imports @lib/wizard-session for a shape; the one remaining WizardSession reference is ProgramRun's session hooks. Generated-By: PostHog Desktop Task-Id: d14e92bb-6ee1-49b5-8502-39cb80079589 --- .../architecture/known-violations.json | 4 +- src/lib/agent/agent-interface.ts | 12 +- src/lib/agent/mcp-prompt-streaming.ts | 2 +- src/lib/agent/progress.ts | 124 +++++++++++- src/lib/agent/runner/harness/types.ts | 2 +- src/lib/agent/runner/sequence/linear.ts | 2 +- .../orchestrator/orchestrator-runner.ts | 2 +- src/lib/agent/runner/shared/types.ts | 13 +- src/lib/api.ts | 25 +++ src/lib/constants.ts | 15 ++ src/lib/wizard-ask-bridge.ts | 2 +- src/lib/wizard-session.ts | 188 +++--------------- 12 files changed, 205 insertions(+), 186 deletions(-) diff --git a/src/__tests__/architecture/known-violations.json b/src/__tests__/architecture/known-violations.json index d83527e0a..57c17d063 100644 --- a/src/__tests__/architecture/known-violations.json +++ b/src/__tests__/architecture/known-violations.json @@ -2,7 +2,6 @@ "violations": [ "src/commands/factories/family-picker.tsx -> src/commands/command.ts", "src/env.ts -> src/lib/headless-mode.ts", - "src/lib/agent/mcp-prompt-streaming.ts -> src/ui/tui/services/mcp-suggested-prompts-services.ts", "src/lib/detection/agentic.ts -> src/lib/agent/agent-interface.ts", "src/lib/detection/project-scope.ts -> src/lib/agent/runner/shared/authenticate.ts", "src/lib/errors/agent-map.ts -> src/lib/agent/signals.ts", @@ -55,7 +54,9 @@ "src/lib/programs/web-analytics-doctor/detect.ts -> src/lib/agent/agent-runner.ts", "src/lib/task-stream/event-plan-watcher.ts -> src/ui/tui/store.ts", "src/lib/task-stream/task-stream-push.ts -> src/ui/tui/store.ts", + "src/lib/wizard-ask-bridge.ts -> src/lib/agent/progress.ts", "src/lib/wizard-session.ts -> src/lib/agent/claude-settings.ts", + "src/lib/wizard-session.ts -> src/lib/agent/progress.ts", "src/lib/wizard-tools/index.ts -> src/lib/wizard-tools/mcp.ts", "src/lib/wizard-tools/tools.ts -> src/lib/yara-hooks.ts", "src/steps/add-mcp-server-to-clients/index.ts -> src/telemetry.ts", @@ -75,6 +76,7 @@ "src/ui/tui/store.ts -> src/lib/agent/claude-settings.ts", "src/ui/tui/store.ts -> src/lib/agent/token-pricing.ts", "src/ui/wizard-ui.ts -> src/lib/agent/claude-settings.ts", + "src/ui/wizard-ui.ts -> src/lib/agent/progress.ts", "src/utils/package-manager.ts -> src/telemetry.ts", "src/utils/setup-utils.ts -> src/telemetry.ts", "src/utils/wizard-abort.ts -> src/ui/logging-ui.ts" diff --git a/src/lib/agent/agent-interface.ts b/src/lib/agent/agent-interface.ts index 45bd05705..3e7a17ad4 100644 --- a/src/lib/agent/agent-interface.ts +++ b/src/lib/agent/agent-interface.ts @@ -25,11 +25,9 @@ import { wizardUserAgentForProgram, DEFAULT_AGENT_MODEL, AWS_SKILLS_BASE_URL, -} from '@lib/constants'; -import { type AdditionalFeature, ADDITIONAL_FEATURE_PROMPTS, -} from '@lib/wizard-session'; +} from '@lib/constants'; import type { AgentFailure } from './runner/shared/types'; import { createCustomHeaders } from '@utils/custom-headers'; import type { HostResolution } from '@lib/host-resolution'; @@ -231,9 +229,7 @@ export type AgentConfig = { * Read accessor for the active pending question. Used by canUseTool to * block Write/Edit while the overlay is open (defense in depth). */ - getPendingQuestion?: () => - | import('@lib/wizard-session').PendingQuestion - | null; + getPendingQuestion?: () => import('./progress').PendingQuestion | null; /** * Orchestrator queue context. Present only when the `wizard-orchestrator` * flag routes the run here; threaded into wizard-tools so the orchestrator @@ -337,9 +333,7 @@ type AgentRunConfig = { * Read accessor for the active pending question. canUseTool reads this * to block Write/Edit while the overlay is open. */ - getPendingQuestion?: () => - | import('@lib/wizard-session').PendingQuestion - | null; + getPendingQuestion?: () => import('./progress').PendingQuestion | null; /** * The orchestrator owns the TUI task panel (it renders its queue), so its * runs suppress the agent's own TaskCreate/TaskUpdate rendering. Set from diff --git a/src/lib/agent/mcp-prompt-streaming.ts b/src/lib/agent/mcp-prompt-streaming.ts index 62bc48e48..f3d0354ec 100644 --- a/src/lib/agent/mcp-prompt-streaming.ts +++ b/src/lib/agent/mcp-prompt-streaming.ts @@ -12,7 +12,7 @@ * `for await (...)` and render as they arrive. */ -import type { Credentials } from '@lib/wizard-session'; +import type { Credentials } from '@lib/api'; import { DEFAULT_AGENT_MODEL, WIZARD_USER_AGENT } from '@lib/constants'; import { logToFile } from '@utils/debug'; import { gatewayAuth } from '@lib/gateway-session'; diff --git a/src/lib/agent/progress.ts b/src/lib/agent/progress.ts index 21550a1d6..f99962a4a 100644 --- a/src/lib/agent/progress.ts +++ b/src/lib/agent/progress.ts @@ -9,14 +9,126 @@ * every existing runner is unchanged. */ -import type { - AskAnswers, - OutroData, - PendingQuestion, - TaskNotice, -} from '@lib/wizard-session'; import type { SettingsConflict } from './claude-settings'; +// ── What the agent hands back and asks with ───────────────────────── + +/** Outcome kind for the outro screen */ +export enum OutroKind { + Success = 'success', + Error = 'error', + Cancel = 'cancel', +} + +export interface OutroData { + kind: OutroKind; + /** Main headline (green check for Success, red X for Error, etc.) */ + message?: string; + /** Free-form body text shown under the headline. Use \n for paragraph breaks. */ + body?: string; + /** Success-only: bulleted list of "what the agent did" */ + changes?: string[]; + /** + * Success-only: a prominent, labeled link to where the user should go + * next (e.g. an inbox the program just configured). Rendered right under + * the headline and shown verbatim — no UTM tagging — so the URL stays + * clean and copy-pasteable. Set per-program in buildOutroData. + */ + primaryLink?: { label: string; url: string }; + /** + * Success-only: a short "what to do next" checklist with its own heading, + * rendered as a bulleted list. Distinct from `changes`, which recaps what + * the agent already did. + */ + nextSteps?: { heading: string; items: string[] }; + docsUrl?: string; + continueUrl?: string; + /** Report file the agent wrote (e.g. "posthog-setup-report.md") */ + reportFile?: string; + /** Stable machine-readable error code from the error catalog (@lib/errors). */ + errorCode?: import('@lib/errors').ErrorCode; + /** Structured context for the error code; safe for telemetry payloads. */ + errorDetail?: Record; + /** PostHog dashboard URL the program created on the user's behalf. */ + dashboardUrl?: string; + /** PostHog notebook URL the program uploaded the report to. */ + notebookUrl?: string; + /** + * Copy-paste prompt the operator hands to their coding agent to finish the + * job (work the report's checklist). Printed to the terminal's main buffer on + * exit (see getExitLine in start-tui.ts) — the TUI's alternate screen is wiped + * on exit, so the scrollback line is where it survives and can be + * triple-click-selected. Set per-program in buildOutroData. + */ + handoffPrompt?: string; +} + +/** A single question rendered by the WizardAsk overlay. */ +export interface AskQuestion { + /** Key for the response map */ + id: string; + prompt: string; + /** text = single-line free input; single/multi = picker */ + kind: 'single' | 'multi' | 'text'; + /** Required for `single` and `multi`. Ignored for `text`. */ + options?: { label: string; value: string; description?: string }[]; + /** Defaults to true */ + required?: boolean; + /** + * Only meaningful for kind='text'. When true, the wizard-tools `wizard_ask` + * tool stores the user's answer in the session secret vault and returns + * `{ secretRef }` to the agent instead of the plain string — so the value + * never enters the LLM conversation. The TUI masks the input as it is typed + * (see `shouldMaskAnswer`). See `secret-vault.ts`. + */ + sensitive?: boolean; +} + +/** + * Copy for a modal shown before an optional step runs, so the user can decline + * it. The program that owns the step supplies the words; the runner and the + * screen only carry them. + */ +export interface TaskNotice { + title: string; + /** Paragraphs, in order. */ + body: string[]; + /** Optional highlighted list, e.g. what was detected. */ + items?: string[]; + docsLabel?: string; + docsUrl?: string; + confirmLabel: string; + cancelLabel: string; + prompt: string; +} + +/** Map of question id → answer (string for single/text, string[] for multi). */ +export type AskAnswers = Record; + +/** A pending wizard_ask request held by the store. */ +export interface PendingQuestion { + id: string; + questions: AskQuestion[]; + /** + * UTC ISO 8601 timestamp of when the ask was created. Published on the + * task stream as `pending_input.asked_at` so the web app can age the + * prompt; stable across pushes for the lifetime of one ask. + */ + askedAt?: string; + /** Skill id of the caller. Set by the wizard from session.skillId. */ + source: string; + /** + * When true, the ask overlay renders standalone URLs in prompt text as + * OSC 8 hyperlinks and copies a lone URL to the clipboard. Opt-in per + * program (set from `ProgramRun.richLinks` via the ask bridge); defaults + * to false so existing flows render prompts exactly as before. See + * `LinkText` / `link-helpers`. + */ + richLinks?: boolean; +} + +// ── What the agent reports ────────────────────────────────────────── + /** * One assistant turn's token usage, for the hidden Ctrl+T token/cost HUD. * `model` is the model that produced *this* turn (e.g. the SDK's diff --git a/src/lib/agent/runner/harness/types.ts b/src/lib/agent/runner/harness/types.ts index 35c082c21..cff145347 100644 --- a/src/lib/agent/runner/harness/types.ts +++ b/src/lib/agent/runner/harness/types.ts @@ -19,7 +19,7 @@ * the run's result. */ -import type { AdditionalFeature } from '@lib/wizard-session'; +import type { AdditionalFeature } from '@lib/constants'; import type { Harness } from '@lib/constants'; import type { WizardAskBridge } from '@lib/wizard-ask-bridge'; import type { AgentErrorType } from '@lib/agent/agent-interface'; diff --git a/src/lib/agent/runner/sequence/linear.ts b/src/lib/agent/runner/sequence/linear.ts index 479330533..3f75d0c5e 100644 --- a/src/lib/agent/runner/sequence/linear.ts +++ b/src/lib/agent/runner/sequence/linear.ts @@ -10,7 +10,7 @@ * arguments, so the caller's exit sequence is unchanged. */ -import { OutroKind, type OutroData } from '@lib/wizard-session'; +import { OutroKind, type OutroData } from '@lib/agent/progress'; import { AgentErrorType, AgentSignals } from '../../agent-interface'; import { logToFile } from '../../../../utils/debug'; import { createBenchmarkPipeline } from '../../../middleware/benchmark'; diff --git a/src/lib/agent/runner/sequence/orchestrator/orchestrator-runner.ts b/src/lib/agent/runner/sequence/orchestrator/orchestrator-runner.ts index 86ac138cd..43a3666a7 100644 --- a/src/lib/agent/runner/sequence/orchestrator/orchestrator-runner.ts +++ b/src/lib/agent/runner/sequence/orchestrator/orchestrator-runner.ts @@ -22,7 +22,7 @@ import { writeFileSync, } from 'fs'; import * as path from 'path'; -import { OutroKind, type TaskNotice } from '@lib/wizard-session'; +import { OutroKind, type TaskNotice } from '@lib/agent/progress'; import { POSTHOG_DOCS_URL, WIZARD_CONTACT_EMAIL } from '@lib/constants'; import { installSkillById, diff --git a/src/lib/agent/runner/shared/types.ts b/src/lib/agent/runner/shared/types.ts index 20961435b..b4f8bec8c 100644 --- a/src/lib/agent/runner/shared/types.ts +++ b/src/lib/agent/runner/shared/types.ts @@ -9,14 +9,11 @@ * that rebuilds today's session-driven behavior on top of this contract. */ -import type { - AdditionalFeature, - CloudRegion, - Credentials, - OutroData, - TaskNotice, - WizardSession, -} from '@lib/wizard-session'; +import type { WizardSession } from '@lib/wizard-session'; +import type { AdditionalFeature } from '@lib/constants'; +import type { CloudRegion } from '@utils/types'; +import type { Credentials } from '@lib/api'; +import type { OutroData, TaskNotice } from '@lib/agent/progress'; import type { PromptContext } from '@lib/agent/agent-prompt'; import type { PackageManagerDetector } from '@lib/detection/package-manager'; import type { ApiProject, ApiUser } from '@lib/api'; diff --git a/src/lib/api.ts b/src/lib/api.ts index 02bf1c04a..7fa1df2e7 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -2,6 +2,7 @@ import axios, { AxiosError } from 'axios'; import { z } from 'zod'; import { analytics } from '@utils/analytics'; import { WIZARD_USER_AGENT } from './constants'; +import type { HostResolution } from './host-resolution'; /** * User payload from `/api/users/@me/`. Schema typed for the fields the @@ -16,6 +17,30 @@ import { WIZARD_USER_AGENT } from './constants'; * Keep `distinct_id` required — analytics depends on it. Everything * else added here is nullish so partial responses don't fail parsing. */ +/** What a login (or a CI api key) resolves to: the wizard's access to one project. */ +export interface Credentials { + accessToken: string; + /** OAuth refresh token when the grant carried one; absent on CI api-key runs. */ + refreshToken?: string; + /** Epoch ms when `accessToken` expires — drives the pre-run refresh. */ + expiresAt?: number; + /** Minting OAuth client when it differs from the default login app (provisioning signups). */ + oauthClientId?: string; + projectApiKey: string; + /** Resolved at auth time and immutable thereafter — see {@link HostResolution}. */ + host: HostResolution; + projectId: number; + /** + * Requested OAuth scopes the grant came back without — deselected on the + * consent screen or clamped by the app's ceiling. Read when a run fails so + * the error can name the missing permission and the fix (re-run and grant + * it during the OAuth flow) instead of the generic report-a-bug line. + * Empty/absent on CI api-key runs, where there is no scope request to diff + * against. + */ + missingScopes?: readonly string[]; +} + export const ApiUserSchema = z .object({ // Identifiers diff --git a/src/lib/constants.ts b/src/lib/constants.ts index a0162e708..1fd68c9c0 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -105,6 +105,21 @@ export enum Integration { javascriptNode = 'javascript_node', } +/** Additional features the agent can integrate after the main setup */ +export enum AdditionalFeature { + LLM = 'llm', +} + +/** Human-readable labels for additional features (used in TUI progress) */ +export const ADDITIONAL_FEATURE_LABELS: Record = { + [AdditionalFeature.LLM]: 'AI observability', +}; + +/** Agent prompts for each additional feature, injected via the stop hook */ +export const ADDITIONAL_FEATURE_PROMPTS: Record = { + [AdditionalFeature.LLM]: `Now integrate AI observability with PostHog. Use the PostHog MCP server to find the appropriate AI observability skill, install it, and follow its workflow. PostHog basics are already installed. Update the setup report markdown file when complete with additions from this task. `, +}; + export interface Args { debug: boolean; integration: Integration; diff --git a/src/lib/wizard-ask-bridge.ts b/src/lib/wizard-ask-bridge.ts index 9dc974d60..290d18ee7 100644 --- a/src/lib/wizard-ask-bridge.ts +++ b/src/lib/wizard-ask-bridge.ts @@ -17,7 +17,7 @@ import type { AskAnswers, AskQuestion, PendingQuestion, -} from './wizard-session'; +} from './agent/progress'; export interface WizardAskRequest { questions: AskQuestion[]; diff --git a/src/lib/wizard-session.ts b/src/lib/wizard-session.ts index 2fc028d59..bb3b8a621 100644 --- a/src/lib/wizard-session.ts +++ b/src/lib/wizard-session.ts @@ -11,35 +11,40 @@ */ import { POSTHOG_LOCAL_URL, resolveLocalDev } from './local-dev'; -import type { Harness, Integration, Sequence } from './constants'; +import { + AdditionalFeature, + ADDITIONAL_FEATURE_LABELS, + ADDITIONAL_FEATURE_PROMPTS, + type Harness, + type Integration, + type Sequence, +} from './constants'; import type { FrameworkConfig } from './framework-config'; import type { WizardReadinessResult } from './health-checks/readiness'; import type { SettingsConflict } from './agent/claude-settings'; -import type { ApiUser, ApiProject } from './api'; -import type { HostResolution } from './host-resolution'; - -export interface Credentials { - accessToken: string; - /** OAuth refresh token when the grant carried one; absent on CI api-key runs. */ - refreshToken?: string; - /** Epoch ms when `accessToken` expires — drives the pre-run refresh. */ - expiresAt?: number; - /** Minting OAuth client when it differs from the default login app (provisioning signups). */ - oauthClientId?: string; - projectApiKey: string; - /** Resolved at auth time and immutable thereafter — see {@link HostResolution}. */ - host: HostResolution; - projectId: number; - /** - * Requested OAuth scopes the grant came back without — deselected on the - * consent screen or clamped by the app's ceiling. Read when a run fails so - * the error can name the missing permission and the fix (re-run and grant - * it during the OAuth flow) instead of the generic report-a-bug line. - * Empty/absent on CI api-key runs, where there is no scope request to diff - * against. - */ - missingScopes?: readonly string[]; -} +import type { ApiUser, ApiProject, Credentials } from './api'; +import type { CloudRegion } from '@utils/types'; +import { + OutroKind, + type AskAnswers, + type AskQuestion, + type OutroData, + type PendingQuestion, + type TaskNotice, +} from './agent/progress'; + +// These shapes moved to their owners; re-exported so every session reader +// keeps its import path. `Credentials` sits with the API types, the +// additional-feature enum with the other program enums in `./constants`, and +// the outro, question and task-notice shapes are the agent's contract. +export type { Credentials, CloudRegion }; +export { + AdditionalFeature, + ADDITIONAL_FEATURE_LABELS, + ADDITIONAL_FEATURE_PROMPTS, +}; +export { OutroKind }; +export type { AskAnswers, AskQuestion, OutroData, PendingQuestion, TaskNotice }; function parseProjectIdArg(value: string | undefined): number | undefined { if (value === undefined || value === '') return undefined; @@ -47,8 +52,6 @@ function parseProjectIdArg(value: string | undefined): number | undefined { return Number.isInteger(n) && n > 0 ? n : undefined; } -export type CloudRegion = 'us' | 'eu'; - /** Lifecycle phase of the main work (agent run, MCP install, etc.) */ export enum RunPhase { /** Still gathering input (intro, setup screens) */ @@ -74,21 +77,6 @@ export enum ScanConsent { Declined = 'declined', } -/** Additional features the agent can integrate after the main setup */ -export enum AdditionalFeature { - LLM = 'llm', -} - -/** Human-readable labels for additional features (used in TUI progress) */ -export const ADDITIONAL_FEATURE_LABELS: Record = { - [AdditionalFeature.LLM]: 'AI observability', -}; - -/** Agent prompts for each additional feature, injected via the stop hook */ -export const ADDITIONAL_FEATURE_PROMPTS: Record = { - [AdditionalFeature.LLM]: `Now integrate AI observability with PostHog. Use the PostHog MCP server to find the appropriate AI observability skill, install it, and follow its workflow. PostHog basics are already installed. Update the setup report markdown file when complete with additions from this task. `, -}; - /** Outcome of the MCP server installation step */ export enum McpOutcome { NoClients = 'no_clients', @@ -97,120 +85,6 @@ export enum McpOutcome { Failed = 'failed', } -/** Outcome kind for the outro screen */ -export enum OutroKind { - Success = 'success', - Error = 'error', - Cancel = 'cancel', -} - -export interface OutroData { - kind: OutroKind; - /** Main headline (green check for Success, red X for Error, etc.) */ - message?: string; - /** Free-form body text shown under the headline. Use \n for paragraph breaks. */ - body?: string; - /** Success-only: bulleted list of "what the agent did" */ - changes?: string[]; - /** - * Success-only: a prominent, labeled link to where the user should go - * next (e.g. an inbox the program just configured). Rendered right under - * the headline and shown verbatim — no UTM tagging — so the URL stays - * clean and copy-pasteable. Set per-program in buildOutroData. - */ - primaryLink?: { label: string; url: string }; - /** - * Success-only: a short "what to do next" checklist with its own heading, - * rendered as a bulleted list. Distinct from `changes`, which recaps what - * the agent already did. - */ - nextSteps?: { heading: string; items: string[] }; - docsUrl?: string; - continueUrl?: string; - /** Report file the agent wrote (e.g. "posthog-setup-report.md") */ - reportFile?: string; - /** Stable machine-readable error code from the error catalog (@lib/errors). */ - errorCode?: import('@lib/errors').ErrorCode; - /** Structured context for the error code; safe for telemetry payloads. */ - errorDetail?: Record; - /** PostHog dashboard URL the program created on the user's behalf. */ - dashboardUrl?: string; - /** PostHog notebook URL the program uploaded the report to. */ - notebookUrl?: string; - /** - * Copy-paste prompt the operator hands to their coding agent to finish the - * job (work the report's checklist). Printed to the terminal's main buffer on - * exit (see getExitLine in start-tui.ts) — the TUI's alternate screen is wiped - * on exit, so the scrollback line is where it survives and can be - * triple-click-selected. Set per-program in buildOutroData. - */ - handoffPrompt?: string; -} - -/** A single question rendered by the WizardAsk overlay. */ -export interface AskQuestion { - /** Key for the response map */ - id: string; - prompt: string; - /** text = single-line free input; single/multi = picker */ - kind: 'single' | 'multi' | 'text'; - /** Required for `single` and `multi`. Ignored for `text`. */ - options?: { label: string; value: string; description?: string }[]; - /** Defaults to true */ - required?: boolean; - /** - * Only meaningful for kind='text'. When true, the wizard-tools `wizard_ask` - * tool stores the user's answer in the session secret vault and returns - * `{ secretRef }` to the agent instead of the plain string — so the value - * never enters the LLM conversation. The TUI masks the input as it is typed - * (see `shouldMaskAnswer`). See `secret-vault.ts`. - */ - sensitive?: boolean; -} - -/** - * Copy for a modal shown before an optional step runs, so the user can decline - * it. The program that owns the step supplies the words; the runner and the - * screen only carry them. - */ -export interface TaskNotice { - title: string; - /** Paragraphs, in order. */ - body: string[]; - /** Optional highlighted list, e.g. what was detected. */ - items?: string[]; - docsLabel?: string; - docsUrl?: string; - confirmLabel: string; - cancelLabel: string; - prompt: string; -} - -/** Map of question id → answer (string for single/text, string[] for multi). */ -export type AskAnswers = Record; - -/** A pending wizard_ask request held by the store. */ -export interface PendingQuestion { - id: string; - questions: AskQuestion[]; - /** - * UTC ISO 8601 timestamp of when the ask was created. Published on the - * task stream as `pending_input.asked_at` so the web app can age the - * prompt; stable across pushes for the lifetime of one ask. - */ - askedAt?: string; - /** Skill id of the caller. Set by the wizard from session.skillId. */ - source: string; - /** - * When true, the ask overlay renders standalone URLs in prompt text as - * OSC 8 hyperlinks and copies a lone URL to the clipboard. Opt-in per - * program (set from `ProgramRun.richLinks` via the ask bridge); defaults - * to false so existing flows render prompts exactly as before. See - * `LinkText` / `link-helpers`. - */ - richLinks?: boolean; -} - /** * PostHog dashboard URL emitted by the agent during a program run. * Populated via the `[DASHBOARD_URL]` text marker in agent assistant messages From 53bddf83a7d8269a95eba1ecb79c7349afe85e24 Mon Sep 17 00:00:00 2001 From: "Vincent (Wen Yu) Ge" Date: Mon, 21 Sep 2026 21:21:06 -0400 Subject: [PATCH 04/13] refactor(programs): split ProgramRun into the agent definition and the program hooks The agent's RunConfig.run is now AgentRunDefinition: prompt, skill, tools, copy, ask policy. ProgramRun extends it in src/lib/programs/program-run.ts with the three session-taking completion hooks (postRun, buildOutroData, buildOutroNextSteps) that only the legacy adapter calls. Programs import ProgramRun from there; the agent no longer references WizardSession anywhere. Generated-By: PostHog Desktop Task-Id: d14e92bb-6ee1-49b5-8502-39cb80079589 --- .../architecture/known-violations.json | 8 +--- src/lib/agent/__tests__/agent-prompt.test.ts | 6 ++- src/lib/agent/agent-interface.ts | 6 +-- src/lib/agent/agent-prompt.ts | 7 ++- src/lib/agent/agent-runner.ts | 2 +- src/lib/agent/progress.ts | 2 +- src/lib/agent/runner/index.ts | 2 +- src/lib/agent/runner/sequence/README.md | 4 +- src/lib/agent/runner/sequence/linear.ts | 2 +- src/lib/agent/runner/shared/types.ts | 47 ++++--------------- .../programs/__tests__/agent-skill.test.ts | 2 +- .../programs/__tests__/error-tracking.test.ts | 2 +- .../__tests__/metrics-program.test.ts | 2 +- src/lib/programs/agent-skill/index.ts | 3 +- src/lib/programs/audit/index.ts | 2 +- .../index.ts | 2 +- src/lib/programs/error-tracking/index.ts | 2 +- src/lib/programs/events-audit/index.ts | 2 +- src/lib/programs/posthog-integration/index.ts | 2 +- src/lib/programs/program-run.ts | 39 +++++++++++++++ src/lib/programs/program-step.ts | 2 +- src/lib/programs/run-agent-legacy.ts | 2 +- src/lib/programs/self-driving/index.ts | 2 +- src/lib/programs/warehouse-source/index.ts | 2 +- 24 files changed, 80 insertions(+), 72 deletions(-) create mode 100644 src/lib/programs/program-run.ts diff --git a/src/__tests__/architecture/known-violations.json b/src/__tests__/architecture/known-violations.json index 57c17d063..86b38fad1 100644 --- a/src/__tests__/architecture/known-violations.json +++ b/src/__tests__/architecture/known-violations.json @@ -9,17 +9,13 @@ "src/lib/programs/agent-skill/index.ts -> src/lib/programs/agent-skill/content/index.tsx", "src/lib/programs/ai-observability/index.ts -> src/lib/programs/agent-skill/content/index.tsx", "src/lib/programs/audit/detect.ts -> src/lib/agent/agent-runner.ts", - "src/lib/programs/audit/index.ts -> src/lib/agent/agent-runner.ts", "src/lib/programs/dispatch-family.ts -> src/commands/command.ts", "src/lib/programs/dispatch-family.ts -> src/commands/factories/shared.ts", "src/lib/programs/error-tracking-upload-source-maps/detect.ts -> src/lib/agent/agent-runner.ts", - "src/lib/programs/error-tracking-upload-source-maps/index.ts -> src/lib/agent/agent-runner.ts", "src/lib/programs/error-tracking-upload-source-maps/index.ts -> src/lib/programs/error-tracking-upload-source-maps/content/index.tsx", "src/lib/programs/error-tracking-upload-source-maps/prompt.ts -> src/lib/agent/agent-interface.ts", - "src/lib/programs/error-tracking/index.ts -> src/lib/agent/runner/shared/types.ts", "src/lib/programs/error-tracking/index.ts -> src/lib/programs/error-tracking/content/index.tsx", "src/lib/programs/error-tracking/index.ts -> src/lib/programs/error-tracking/content/tips.ts", - "src/lib/programs/events-audit/index.ts -> src/lib/agent/agent-runner.ts", "src/lib/programs/mcp-analytics/index.ts -> src/lib/agent/agent-runner.ts", "src/lib/programs/metrics/index.ts -> src/lib/programs/agent-skill/content/index.tsx", "src/lib/programs/migration/index.ts -> src/lib/agent/agent-runner.ts", @@ -28,7 +24,7 @@ "src/lib/programs/posthog-integration/index.ts -> src/lib/agent/agent-runner.ts", "src/lib/programs/posthog-integration/index.ts -> src/lib/programs/posthog-integration/content/index.tsx", "src/lib/programs/program-registry.ts -> src/lib/programs/agent-skill/content/index.tsx", - "src/lib/programs/program-step.ts -> src/lib/agent/agent-runner.ts", + "src/lib/programs/program-run.ts -> src/lib/agent/runner/index.ts", "src/lib/programs/program-step.ts -> src/ui/tui/components/TipsCard.tsx", "src/lib/programs/program-step.ts -> src/ui/tui/primitives/index.ts", "src/lib/programs/program-step.ts -> src/ui/tui/store.ts", @@ -42,14 +38,12 @@ "src/lib/programs/run-agent-legacy.ts -> src/lib/agent/runner/switchboard/index.ts", "src/lib/programs/run-agent-legacy.ts -> src/lib/yara-hooks.ts", "src/lib/programs/self-driving/detect.ts -> src/lib/agent/agent-runner.ts", - "src/lib/programs/self-driving/index.ts -> src/lib/agent/agent-runner.ts", "src/lib/programs/self-driving/index.ts -> src/lib/programs/self-driving/content/index.tsx", "src/lib/programs/self-driving/index.ts -> src/lib/programs/self-driving/content/pricing.ts", "src/lib/programs/self-driving/index.ts -> src/lib/programs/self-driving/content/tips.ts", "src/lib/programs/self-driving/prompt.ts -> src/lib/agent/agent-interface.ts", "src/lib/programs/self-driving/prompt.ts -> src/lib/agent/agent-runner.ts", "src/lib/programs/warehouse-source/detect.ts -> src/lib/agent/agent-runner.ts", - "src/lib/programs/warehouse-source/index.ts -> src/lib/agent/agent-runner.ts", "src/lib/programs/warehouse-source/index.ts -> src/lib/programs/warehouse-source/content/index.tsx", "src/lib/programs/web-analytics-doctor/detect.ts -> src/lib/agent/agent-runner.ts", "src/lib/task-stream/event-plan-watcher.ts -> src/ui/tui/store.ts", diff --git a/src/lib/agent/__tests__/agent-prompt.test.ts b/src/lib/agent/__tests__/agent-prompt.test.ts index 2ee6bfdb9..e6bad880c 100644 --- a/src/lib/agent/__tests__/agent-prompt.test.ts +++ b/src/lib/agent/__tests__/agent-prompt.test.ts @@ -1,8 +1,10 @@ import { assemblePrompt, type PromptContext } from '@lib/agent/agent-prompt'; -import type { ProgramRun } from '@lib/agent/agent-runner'; +import type { AgentRunDefinition } from '@lib/agent/runner'; import { HostResolution } from '@lib/host-resolution'; -function makeRunDef(overrides: Partial = {}): ProgramRun { +function makeRunDef( + overrides: Partial = {}, +): AgentRunDefinition { return { integrationLabel: 'test', spinnerMessage: 'Working...', diff --git a/src/lib/agent/agent-interface.ts b/src/lib/agent/agent-interface.ts index 3e7a17ad4..283420ad6 100644 --- a/src/lib/agent/agent-interface.ts +++ b/src/lib/agent/agent-interface.ts @@ -729,12 +729,12 @@ export async function runAgent( abortCases?: readonly AbortCaseMatcher[]; /** * Emit a `wizard: step` event on each agent task transition. Threaded from - * `ProgramRun.trackStepProgress`; defaults off for every other caller. + * `AgentRunDefinition.trackStepProgress`; defaults off for every other caller. */ emitStepEvents?: boolean; /** * Maps an agent-authored step label to a stable `step_key`. Threaded from - * `ProgramRun.resolveStepKey`; absent for programs that don't define one. + * `AgentRunDefinition.resolveStepKey`; absent for programs that don't define one. */ resolveStepKey?: (stepName: string | undefined) => string | undefined; /** Request the end-of-run reflection remark. Defaults to true. */ @@ -1783,7 +1783,7 @@ function handleSDKMessage( // agent's own TaskCreate/TaskUpdate rendering so it does not clobber the queue. suppressTaskRender = false, // Opt-in per-step analytics, threaded from runAgent's `emitStepEvents` - // (ProgramRun.trackStepProgress). Off for every program that doesn't opt in. + // (AgentRunDefinition.trackStepProgress). Off for every program that doesn't opt in. emitStepEvents = false, // Program-supplied label -> stable key mapping for the same events. resolveStepKey?: (stepName: string | undefined) => string | undefined, diff --git a/src/lib/agent/agent-prompt.ts b/src/lib/agent/agent-prompt.ts index 0427fc4f5..4987adcfb 100644 --- a/src/lib/agent/agent-prompt.ts +++ b/src/lib/agent/agent-prompt.ts @@ -7,7 +7,7 @@ * 3. Skill prompt — "follow SKILL.md" instructions (if a skill was installed) */ -import type { ProgramRun } from './agent-runner.js'; +import type { AgentRunDefinition } from './runner/shared/types'; import type { HostResolution } from '@lib/host-resolution'; /** @@ -62,7 +62,10 @@ Important: You must read a file immediately before attempting to write it, even /** * Assemble the final agent prompt from the program's run config. */ -export function assemblePrompt(runDef: ProgramRun, ctx: PromptContext): string { +export function assemblePrompt( + runDef: AgentRunDefinition, + ctx: PromptContext, +): string { const parts: string[] = []; // Always include the default project prompt diff --git a/src/lib/agent/agent-runner.ts b/src/lib/agent/agent-runner.ts index 2850afcc1..7ffac5145 100644 --- a/src/lib/agent/agent-runner.ts +++ b/src/lib/agent/agent-runner.ts @@ -8,7 +8,7 @@ export { runAgent, shouldDisableAsk, - type ProgramRun, + type AgentRunDefinition, type BootstrapResult, type AbortCase, type PromptContext, diff --git a/src/lib/agent/progress.ts b/src/lib/agent/progress.ts index f99962a4a..eb5386ff4 100644 --- a/src/lib/agent/progress.ts +++ b/src/lib/agent/progress.ts @@ -120,7 +120,7 @@ export interface PendingQuestion { /** * When true, the ask overlay renders standalone URLs in prompt text as * OSC 8 hyperlinks and copies a lone URL to the clipboard. Opt-in per - * program (set from `ProgramRun.richLinks` via the ask bridge); defaults + * program (set from `AgentRunDefinition.richLinks` via the ask bridge); defaults * to false so existing flows render prompts exactly as before. See * `LinkText` / `link-helpers`. */ diff --git a/src/lib/agent/runner/index.ts b/src/lib/agent/runner/index.ts index 10ea0bdf8..70f213775 100644 --- a/src/lib/agent/runner/index.ts +++ b/src/lib/agent/runner/index.ts @@ -43,7 +43,7 @@ export type { AgentFailure, BootstrapResult, Credentials, - ProgramRun, + AgentRunDefinition, PromptContext, ResolvedBinding, RunAgentOptions, diff --git a/src/lib/agent/runner/sequence/README.md b/src/lib/agent/runner/sequence/README.md index 038fdeb01..d50ce96ad 100644 --- a/src/lib/agent/runner/sequence/README.md +++ b/src/lib/agent/runner/sequence/README.md @@ -12,8 +12,8 @@ prompt assembly, error routing, post-run work, and outro construction. Retain it for very simple tasks and legacy support. Its context is subject to the harness's compaction behavior. -`ProgramRun.customPrompt`, `abortCases`, `postRun`, and `buildOutroData` are -linear hooks. The orchestrator does not invoke them. Composed program sub-runs +`AgentRunDefinition.customPrompt`, `abortCases`, and the program's `postRun` +and `buildOutroData` hooks are linear hooks. The orchestrator does not invoke them. Composed program sub-runs are also clamped to linear because an orchestrator owns its full lifecycle and cannot nest through the composition seam. diff --git a/src/lib/agent/runner/sequence/linear.ts b/src/lib/agent/runner/sequence/linear.ts index 3f75d0c5e..8fe2a8df9 100644 --- a/src/lib/agent/runner/sequence/linear.ts +++ b/src/lib/agent/runner/sequence/linear.ts @@ -1,7 +1,7 @@ /** * The linear pipeline. Single execution path for all non-orchestrator programs, * both skill-based (revenue analytics) and framework-based (core integration). - * The `ProgramRun` controls what varies between them; `RunConfig` + * The `AgentRunDefinition` controls what varies between them; `RunConfig` * carries the program-level static metadata (tool allow/disallow lists, etc.). * * Reports through `emit`, asks through `interaction`, and returns a decided diff --git a/src/lib/agent/runner/shared/types.ts b/src/lib/agent/runner/shared/types.ts index b4f8bec8c..82df5779b 100644 --- a/src/lib/agent/runner/shared/types.ts +++ b/src/lib/agent/runner/shared/types.ts @@ -9,7 +9,6 @@ * that rebuilds today's session-driven behavior on top of this contract. */ -import type { WizardSession } from '@lib/wizard-session'; import type { AdditionalFeature } from '@lib/constants'; import type { CloudRegion } from '@utils/types'; import type { Credentials } from '@lib/api'; @@ -39,17 +38,14 @@ export interface AbortCase { } /** - * Unified agent run configuration. + * What varies between agent runs: the prompt, the skill, the tools, the copy. * - * Every program provides one of these — either as a static object - * or via a function that builds one from the session. The runner - * assembles the final prompt from `prompt` + `skillId`. - * - * The three session-taking hooks at the end are the caller's: the agent never - * calls them. `run-agent-legacy.ts` binds them and hands the agent - * `RunConfig.hooks` instead. + * Every program provides one of these as `RunConfig.run`. The runner assembles + * the final prompt from `customPrompt` + `skillId`. Programs extend it with + * their session-taking completion hooks in `src/lib/programs/program-run.ts`; + * the caller binds those and hands the agent `RunConfig.hooks` instead. */ -export interface ProgramRun { +export interface AgentRunDefinition { /** Analytics label (e.g. 'revenue-analytics-setup', 'nextjs') */ integrationLabel: string; /** Skill ID to pre-install. Omit for agent-driven skill discovery. */ @@ -69,33 +65,6 @@ export interface ProgramRun { additionalFeatureQueue?: readonly AdditionalFeature[]; /** Known `[ABORT] ` cases this program can render. */ abortCases?: AbortCase[]; - /** Runs after agent completes, before outro (e.g. env var upload). */ - postRun?: (session: WizardSession, credentials: Credentials) => Promise; - /** Custom outro data. Omit for default built from successMessage/reportFile/docsUrl. */ - buildOutroData?: ( - session: WizardSession, - credentials: Credentials, - ) => WizardSession['outroData']; - /** - * Outro bullets for a sequence that composes its own outro data. - * - * `buildOutroData` is the linear sequence's seam: it hands the program the - * whole outro. The orchestrated sequence cannot, because its message is the - * drain's result — how many steps ran, what was skipped, which conflict the - * review step left. So a program with next steps to offer had nowhere to put - * them there, and the integration's data-source links were built and then - * dropped on every orchestrated run. This hook keeps the message with the - * sequence and the bullets with the program. - * - * `completedSeededTypes` names the runner-seeded task types that finished - * successfully, so a program can leave out a step its own seeded task - * already did — the sequence stays ignorant of what any type means. - */ - buildOutroNextSteps?: ( - session: WizardSession, - credentials: Credentials, - completedSeededTypes: readonly string[], - ) => { heading: string; items: string[] } | undefined; /** * Per-run cap on `wizard_ask` invocations. Defaults to 10. The 4th call * always returns a "batch your questions" error regardless of the cap. @@ -176,8 +145,8 @@ export interface ResolvedBinding { export interface RunConfig { /** Program id: gateway spend pin, analytics label, commandments axis. */ programId: string; - /** The program's run definition. Its session-taking hooks are the caller's, see `hooks`. */ - run: ProgramRun; + /** The run definition. A program's session-taking hooks are the caller's, see `hooks`. */ + run: AgentRunDefinition; /** A composed sub-run leaves the terminal outro to its host. */ composed: boolean; /** Run-level sequence, harness and model. */ diff --git a/src/lib/programs/__tests__/agent-skill.test.ts b/src/lib/programs/__tests__/agent-skill.test.ts index 132586e1d..b836f0486 100644 --- a/src/lib/programs/__tests__/agent-skill.test.ts +++ b/src/lib/programs/__tests__/agent-skill.test.ts @@ -3,7 +3,7 @@ import { AGENT_SKILL_STEPS, type SkillProgramOptions, } from '@lib/programs/agent-skill/index'; -import type { ProgramRun } from '@lib/agent/agent-runner'; +import type { ProgramRun } from '@lib/programs/program-run'; import { buildSession, RunPhase } from '@lib/wizard-session'; import { HostResolution } from '@lib/host-resolution'; diff --git a/src/lib/programs/__tests__/error-tracking.test.ts b/src/lib/programs/__tests__/error-tracking.test.ts index 7cf0561d8..5ecef5279 100644 --- a/src/lib/programs/__tests__/error-tracking.test.ts +++ b/src/lib/programs/__tests__/error-tracking.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, test, vi } from 'vitest'; -import type { ProgramRun } from '@lib/agent/runner/shared/types'; +import type { ProgramRun } from '@lib/programs/program-run'; import { Integration } from '@lib/constants'; import type { AgenticDetectionReport } from '@lib/detection/agentic'; import { detectFramework } from '@lib/detection/index'; diff --git a/src/lib/programs/__tests__/metrics-program.test.ts b/src/lib/programs/__tests__/metrics-program.test.ts index ae5936408..46f381471 100644 --- a/src/lib/programs/__tests__/metrics-program.test.ts +++ b/src/lib/programs/__tests__/metrics-program.test.ts @@ -1,7 +1,7 @@ import { AGENT_SKILL_STEPS } from '@lib/programs/agent-skill/index'; import { getProgramConfig, Program } from '@lib/programs/program-registry'; import { metricsConfig } from '@lib/programs/metrics/index'; -import type { ProgramRun } from '@lib/agent/agent-runner'; +import type { ProgramRun } from '@lib/programs/program-run'; import { metricsCommand } from '../../../commands/metrics'; diff --git a/src/lib/programs/agent-skill/index.ts b/src/lib/programs/agent-skill/index.ts index dbd207ae0..ace6ab19d 100644 --- a/src/lib/programs/agent-skill/index.ts +++ b/src/lib/programs/agent-skill/index.ts @@ -20,7 +20,8 @@ */ import type { ProgramConfig } from '@lib/programs/program-step'; -import type { ProgramRun, AbortCase } from '@lib/agent/agent-runner'; +import type { AbortCase } from '@lib/agent/agent-runner'; +import type { ProgramRun } from '@lib/programs/program-run'; import { AGENT_SKILL_STEPS } from './steps.js'; import { getContentBlocks } from './content/index.js'; diff --git a/src/lib/programs/audit/index.ts b/src/lib/programs/audit/index.ts index fc5f60297..261fbaefc 100644 --- a/src/lib/programs/audit/index.ts +++ b/src/lib/programs/audit/index.ts @@ -3,7 +3,7 @@ import { createSkillProgram, } from '@lib/programs/agent-skill/index'; import type { ProgramStep, ProgramConfig } from '@lib/programs/program-step'; -import type { ProgramRun } from '@lib/agent/agent-runner'; +import type { ProgramRun } from '@lib/programs/program-run'; import type { WizardSession } from '@lib/wizard-session'; import { OutroKind } from '@lib/wizard-session'; import { WIZARD_TOOL_NAMES } from '@lib/wizard-tools'; diff --git a/src/lib/programs/error-tracking-upload-source-maps/index.ts b/src/lib/programs/error-tracking-upload-source-maps/index.ts index c79f245e2..afdc17007 100644 --- a/src/lib/programs/error-tracking-upload-source-maps/index.ts +++ b/src/lib/programs/error-tracking-upload-source-maps/index.ts @@ -1,5 +1,5 @@ import type { ProgramConfig } from '@lib/programs/program-step'; -import type { ProgramRun } from '@lib/agent/agent-runner'; +import type { ProgramRun } from '@lib/programs/program-run'; import type { WizardSession } from '@lib/wizard-session'; import { OutroKind } from '@lib/wizard-session'; import { ERROR_TRACKING_UPLOAD_SOURCE_MAPS_PROGRAM } from './steps.js'; diff --git a/src/lib/programs/error-tracking/index.ts b/src/lib/programs/error-tracking/index.ts index 2fd6a7cb1..68e7986b5 100644 --- a/src/lib/programs/error-tracking/index.ts +++ b/src/lib/programs/error-tracking/index.ts @@ -2,7 +2,7 @@ import { Integration } from '@lib/constants'; import { detectFramework } from '@lib/detection/index'; import { scopeInstallDirToProject } from '@lib/detection/project-scope'; import { FRAMEWORK_REGISTRY } from '@lib/registry'; -import type { ProgramRun } from '@lib/agent/runner/shared/types'; +import type { ProgramRun } from '@lib/programs/program-run'; import { AGENT_SKILL_STEPS } from '@lib/programs/agent-skill/steps'; import { getContentBlocks } from '@lib/programs/error-tracking/content/index'; import { getTips } from '@lib/programs/error-tracking/content/tips'; diff --git a/src/lib/programs/events-audit/index.ts b/src/lib/programs/events-audit/index.ts index 080e59d69..4cfa397f0 100644 --- a/src/lib/programs/events-audit/index.ts +++ b/src/lib/programs/events-audit/index.ts @@ -1,5 +1,5 @@ import type { ProgramConfig } from '@lib/programs/program-step'; -import type { ProgramRun } from '@lib/agent/agent-runner'; +import type { ProgramRun } from '@lib/programs/program-run'; import type { WizardSession } from '@lib/wizard-session'; import { OutroKind } from '@lib/wizard-session'; import { SPINNER_MESSAGE } from '@lib/framework-config'; diff --git a/src/lib/programs/posthog-integration/index.ts b/src/lib/programs/posthog-integration/index.ts index c7ea2560a..9fc7aab8e 100644 --- a/src/lib/programs/posthog-integration/index.ts +++ b/src/lib/programs/posthog-integration/index.ts @@ -1,6 +1,6 @@ import type { ProgramConfig, ProgramStep } from '@lib/programs/program-step'; import { runProgramAgent } from '@lib/programs/run-agent-legacy'; -import type { ProgramRun } from '@lib/agent/agent-runner'; +import type { ProgramRun } from '@lib/programs/program-run'; import { WIZARD_TOOL_NAMES } from '@lib/wizard-tools'; import type { WizardSession } from '@lib/wizard-session'; import { mayReportScanResults, OutroKind, RunPhase } from '@lib/wizard-session'; diff --git a/src/lib/programs/program-run.ts b/src/lib/programs/program-run.ts new file mode 100644 index 000000000..35a3ea90f --- /dev/null +++ b/src/lib/programs/program-run.ts @@ -0,0 +1,39 @@ +/** + * A program's run definition: the agent's `AgentRunDefinition` plus the + * completion hooks that read the session. The agent never calls these — + * `run-agent-legacy.ts` binds them to the run's credentials and hands the + * agent `RunConfig.hooks`. + */ + +import type { AgentRunDefinition } from '@lib/agent/runner'; +import type { Credentials, WizardSession } from '@lib/wizard-session'; + +export interface ProgramRun extends AgentRunDefinition { + /** Runs after agent completes, before outro (e.g. env var upload). */ + postRun?: (session: WizardSession, credentials: Credentials) => Promise; + /** Custom outro data. Omit for default built from successMessage/reportFile/docsUrl. */ + buildOutroData?: ( + session: WizardSession, + credentials: Credentials, + ) => WizardSession['outroData']; + /** + * Outro bullets for a sequence that composes its own outro data. + * + * `buildOutroData` is the linear sequence's seam: it hands the program the + * whole outro. The orchestrated sequence cannot, because its message is the + * drain's result — how many steps ran, what was skipped, which conflict the + * review step left. So a program with next steps to offer had nowhere to put + * them there, and the integration's data-source links were built and then + * dropped on every orchestrated run. This hook keeps the message with the + * sequence and the bullets with the program. + * + * `completedSeededTypes` names the runner-seeded task types that finished + * successfully, so a program can leave out a step its own seeded task + * already did — the sequence stays ignorant of what any type means. + */ + buildOutroNextSteps?: ( + session: WizardSession, + credentials: Credentials, + completedSeededTypes: readonly string[], + ) => { heading: string; items: string[] } | undefined; +} diff --git a/src/lib/programs/program-step.ts b/src/lib/programs/program-step.ts index 81e55a655..69d0d7cfd 100644 --- a/src/lib/programs/program-step.ts +++ b/src/lib/programs/program-step.ts @@ -4,7 +4,7 @@ import type { TaskNotice, } from '@lib/wizard-session'; import type { WizardReadinessResult } from '@lib/health-checks/readiness'; -import type { ProgramRun } from '@lib/agent/agent-runner'; +import type { ProgramRun } from '@lib/programs/program-run'; import type { Integration } from '@lib/constants'; import type { FrameworkConfig } from '@lib/framework-config'; import type { ContentBlock } from '@ui/tui/primitives/index'; diff --git a/src/lib/programs/run-agent-legacy.ts b/src/lib/programs/run-agent-legacy.ts index d8315cb88..81bf0a686 100644 --- a/src/lib/programs/run-agent-legacy.ts +++ b/src/lib/programs/run-agent-legacy.ts @@ -21,12 +21,12 @@ import { runAgent, RunOutcome, resolveBinding, - type ProgramRun, type RunConfig, type RunInput, type SwitchboardCtx, } from '@lib/agent/runner'; import type { ProgramBinding } from '@lib/agent/runner/switchboard'; +import type { ProgramRun } from './program-run'; import { buildRunTags } from '@lib/agent/agent-interface'; import { backupAndFixClaudeSettings, diff --git a/src/lib/programs/self-driving/index.ts b/src/lib/programs/self-driving/index.ts index b507cc18e..38a0ad930 100644 --- a/src/lib/programs/self-driving/index.ts +++ b/src/lib/programs/self-driving/index.ts @@ -1,7 +1,7 @@ import { join } from 'path'; import { access, rm } from 'node:fs/promises'; import type { ProgramConfig } from '@lib/programs/program-step'; -import type { ProgramRun } from '@lib/agent/agent-runner'; +import type { ProgramRun } from '@lib/programs/program-run'; import { OutroKind, type WizardSession } from '@lib/wizard-session'; import { createSkillProgram } from '../agent-skill/index.js'; import { SELF_DRIVING_PROGRAM } from './steps.js'; diff --git a/src/lib/programs/warehouse-source/index.ts b/src/lib/programs/warehouse-source/index.ts index 059e240a0..4b31f3851 100644 --- a/src/lib/programs/warehouse-source/index.ts +++ b/src/lib/programs/warehouse-source/index.ts @@ -1,5 +1,5 @@ import type { ProgramConfig } from '@lib/programs/program-step'; -import type { ProgramRun } from '@lib/agent/agent-runner'; +import type { ProgramRun } from '@lib/programs/program-run'; import type { WizardSession } from '@lib/wizard-session'; import { LONGER_ASK_TIMEOUT_MS } from '@lib/wizard-ask-bridge'; import { WAREHOUSE_SOURCE_PROGRAM } from './steps.js'; From 54f8e4be478ebe54ba79c3a8582cb9d66d25e42f Mon Sep 17 00:00:00 2001 From: "Vincent (Wen Yu) Ge" Date: Mon, 21 Sep 2026 21:22:44 -0400 Subject: [PATCH 05/13] refactor(programs): move authenticate and the token refresh to programs Both read and write the session, drive the OAuth flow through the UI and take a ProgramId. Plan section 4.5: programs own authentication and refresh. File and test move unchanged; five importers repoint. Generated-By: PostHog Desktop Task-Id: d14e92bb-6ee1-49b5-8502-39cb80079589 --- scripts/tui-host.no-jest.ts | 2 +- src/__tests__/architecture/known-violations.json | 2 -- src/lib/detection/__tests__/project-scope.test.ts | 4 ++-- src/lib/detection/project-scope.ts | 2 +- .../__tests__/refresh-access-token-if-needed.test.ts | 0 src/lib/programs/__tests__/run-agent-legacy.test.ts | 4 ++-- src/lib/{agent/runner/shared => programs}/authenticate.ts | 0 src/lib/programs/run-agent-legacy.ts | 5 +---- src/lib/runners/run-wizard.ts | 2 +- 9 files changed, 8 insertions(+), 13 deletions(-) rename src/lib/{agent/runner/shared => programs}/__tests__/refresh-access-token-if-needed.test.ts (100%) rename src/lib/{agent/runner/shared => programs}/authenticate.ts (100%) diff --git a/scripts/tui-host.no-jest.ts b/scripts/tui-host.no-jest.ts index d20bfc8cb..61ca921bf 100644 --- a/scripts/tui-host.no-jest.ts +++ b/scripts/tui-host.no-jest.ts @@ -30,7 +30,7 @@ import { configureGatewayFromCIEnvironment } from '@lib/gateway-session'; import { runProgramAgent } from '@lib/programs/run-agent-legacy'; import { TaskStreamPush, createFileDestination } from '@lib/task-stream/index'; import { getAuditChecks } from '@lib/programs/audit/types'; -import { authenticate } from '@lib/agent/runner/shared/authenticate'; +import { authenticate } from '@lib/programs/authenticate'; import { getOrAskForProjectData } from '@utils/setup-utils'; import { logToFile } from '@utils/debug'; import { join } from 'path'; diff --git a/src/__tests__/architecture/known-violations.json b/src/__tests__/architecture/known-violations.json index 86b38fad1..d270a05bb 100644 --- a/src/__tests__/architecture/known-violations.json +++ b/src/__tests__/architecture/known-violations.json @@ -3,7 +3,6 @@ "src/commands/factories/family-picker.tsx -> src/commands/command.ts", "src/env.ts -> src/lib/headless-mode.ts", "src/lib/detection/agentic.ts -> src/lib/agent/agent-interface.ts", - "src/lib/detection/project-scope.ts -> src/lib/agent/runner/shared/authenticate.ts", "src/lib/errors/agent-map.ts -> src/lib/agent/signals.ts", "src/lib/programs/agent-skill/index.ts -> src/lib/agent/agent-runner.ts", "src/lib/programs/agent-skill/index.ts -> src/lib/programs/agent-skill/content/index.tsx", @@ -34,7 +33,6 @@ "src/lib/programs/run-agent-legacy.ts -> src/lib/agent/agent-interface.ts", "src/lib/programs/run-agent-legacy.ts -> src/lib/agent/claude-settings.ts", "src/lib/programs/run-agent-legacy.ts -> src/lib/agent/runner/index.ts", - "src/lib/programs/run-agent-legacy.ts -> src/lib/agent/runner/shared/authenticate.ts", "src/lib/programs/run-agent-legacy.ts -> src/lib/agent/runner/switchboard/index.ts", "src/lib/programs/run-agent-legacy.ts -> src/lib/yara-hooks.ts", "src/lib/programs/self-driving/detect.ts -> src/lib/agent/agent-runner.ts", diff --git a/src/lib/detection/__tests__/project-scope.test.ts b/src/lib/detection/__tests__/project-scope.test.ts index 46aedaf04..18f9173dc 100644 --- a/src/lib/detection/__tests__/project-scope.test.ts +++ b/src/lib/detection/__tests__/project-scope.test.ts @@ -10,12 +10,12 @@ import { AGENTIC_DETECTION_TIMEOUT_MS, WIZARD_BASIC_INTEGRATION_AGENTIC_DETECTION_FLAG_KEY, } from '@lib/constants'; -import { authenticate } from '@lib/agent/runner/shared/authenticate'; +import { authenticate } from '@lib/programs/authenticate'; import { buildSession } from '@lib/wizard-session'; import { analytics } from '@utils/analytics'; // Mock only the two network edges of scopeInstallDirToProject; everything else runs real. -vi.mock('@lib/agent/runner/shared/authenticate', () => ({ +vi.mock('@lib/programs/authenticate', () => ({ authenticate: vi.fn().mockResolvedValue(undefined), })); vi.mock('@lib/detection/agentic', async (importOriginal) => ({ diff --git a/src/lib/detection/project-scope.ts b/src/lib/detection/project-scope.ts index 73279b075..da4ffaf0a 100644 --- a/src/lib/detection/project-scope.ts +++ b/src/lib/detection/project-scope.ts @@ -8,7 +8,7 @@ import { type DetectEvent, type DetectTarget, } from './agentic.js'; -import { authenticate } from '@lib/agent/runner/shared/authenticate'; +import { authenticate } from '@lib/programs/authenticate'; import { FRAMEWORK_REGISTRY } from '@lib/registry'; import { AGENTIC_DETECTION_TIMEOUT_MS, diff --git a/src/lib/agent/runner/shared/__tests__/refresh-access-token-if-needed.test.ts b/src/lib/programs/__tests__/refresh-access-token-if-needed.test.ts similarity index 100% rename from src/lib/agent/runner/shared/__tests__/refresh-access-token-if-needed.test.ts rename to src/lib/programs/__tests__/refresh-access-token-if-needed.test.ts diff --git a/src/lib/programs/__tests__/run-agent-legacy.test.ts b/src/lib/programs/__tests__/run-agent-legacy.test.ts index 6a0b9b1c4..c2ad3dead 100644 --- a/src/lib/programs/__tests__/run-agent-legacy.test.ts +++ b/src/lib/programs/__tests__/run-agent-legacy.test.ts @@ -1,5 +1,5 @@ import { runNonInteractive } from '@lib/runners/run-non-interactive'; -import { authenticate } from '@lib/agent/runner/shared/authenticate'; +import { authenticate } from '@lib/programs/authenticate'; import { runProgramAgent } from '../run-agent-legacy'; import { runAgent, RunOutcome, type RunResult } from '@lib/agent/runner'; import { Harness, Sequence } from '@lib/constants'; @@ -54,7 +54,7 @@ vi.mock('@lib/agent/runner', async (original) => ({ ...(await original()), runAgent: vi.fn(), })); -vi.mock('@lib/agent/runner/shared/authenticate', () => ({ +vi.mock('@lib/programs/authenticate', () => ({ authenticate: vi.fn().mockResolvedValue(undefined), refreshAccessTokenIfNeeded: vi.fn().mockResolvedValue(undefined), })); diff --git a/src/lib/agent/runner/shared/authenticate.ts b/src/lib/programs/authenticate.ts similarity index 100% rename from src/lib/agent/runner/shared/authenticate.ts rename to src/lib/programs/authenticate.ts diff --git a/src/lib/programs/run-agent-legacy.ts b/src/lib/programs/run-agent-legacy.ts index 81bf0a686..87b8f8e36 100644 --- a/src/lib/programs/run-agent-legacy.ts +++ b/src/lib/programs/run-agent-legacy.ts @@ -55,10 +55,7 @@ import { } from '@lib/constants'; import { FRAMEWORK_REGISTRY } from '@lib/registry'; import { postAuthGateSteps, type ProgramConfig } from './program-step'; -import { - authenticate, - refreshAccessTokenIfNeeded, -} from '@lib/agent/runner/shared/authenticate'; +import { authenticate, refreshAccessTokenIfNeeded } from './authenticate'; import { maybeStampAiSdkDetected } from './posthog-integration/detect'; import { startAuditLedgerWatcher } from './audit/ledger-watcher'; diff --git a/src/lib/runners/run-wizard.ts b/src/lib/runners/run-wizard.ts index 42b257cd5..a1f5c5390 100644 --- a/src/lib/runners/run-wizard.ts +++ b/src/lib/runners/run-wizard.ts @@ -1,7 +1,7 @@ import { VERSION } from '@lib/version'; import { logToFile, getLogFilePath } from '@utils/debug'; import { runProgramAgent } from '@lib/programs/run-agent-legacy'; -import { authenticate } from '@lib/agent/runner/shared/authenticate'; +import { authenticate } from '@lib/programs/authenticate'; import { getProgramConfig } from '@lib/programs/program-registry'; import { getAuditChecks } from '@lib/programs/audit/types'; import { maybeStampAiSdkDetected } from '@lib/programs/posthog-integration/detect'; From 85ed50e4641c9588bc90a27cda1104b8473f85d6 Mon Sep 17 00:00:00 2001 From: "Vincent (Wen Yu) Ge" Date: Mon, 21 Sep 2026 21:25:12 -0400 Subject: [PATCH 06/13] refactor(shared): name program documents and the audit ledger outside programs yara-hooks and the audit ledger tools imported filename constants and the AuditCheck shape from three programs. The document names now live in @lib/constants, the ledger contract (file, check shape, read-side coercion) in the new leaf @lib/audit-ledger, and each program re-exports its own, so the audit views and the ledger watcher are unchanged. Nothing agent-side imports @lib/programs at runtime any more. Generated-By: PostHog Desktop Task-Id: d14e92bb-6ee1-49b5-8502-39cb80079589 --- src/lib/agent/runner/harness/pi/tools.ts | 7 +++- src/lib/audit-ledger.ts | 37 +++++++++++++++++ src/lib/constants.ts | 12 ++++++ src/lib/programs/audit/types.ts | 40 +++++-------------- src/lib/programs/events-audit/constants.ts | 18 ++++----- .../programs/posthog-integration/constants.ts | 11 ++--- src/lib/wizard-tools/mcp.ts | 2 +- src/lib/wizard-tools/tools.ts | 2 +- src/lib/yara-hooks.ts | 22 +++++----- 9 files changed, 88 insertions(+), 63 deletions(-) create mode 100644 src/lib/audit-ledger.ts diff --git a/src/lib/agent/runner/harness/pi/tools.ts b/src/lib/agent/runner/harness/pi/tools.ts index b194cc79a..06121652c 100644 --- a/src/lib/agent/runner/harness/pi/tools.ts +++ b/src/lib/agent/runner/harness/pi/tools.ts @@ -62,8 +62,11 @@ import { publishHandoff, } from '@lib/wizard-tools/handoff'; import { createSecretVault } from '@lib/secret-vault'; -import { AUDIT_CHECKS_FILE } from '@lib/programs/audit/types'; -import type { AuditCheck, AuditStatus } from '@lib/programs/audit/types'; +import { + AUDIT_CHECKS_FILE, + type AuditCheck, + type AuditStatus, +} from '@lib/audit-ledger'; import { makeMutex } from '@utils/atomic-ledger'; import { withMode } from './index'; import { diff --git a/src/lib/audit-ledger.ts b/src/lib/audit-ledger.ts new file mode 100644 index 000000000..74228ef09 --- /dev/null +++ b/src/lib/audit-ledger.ts @@ -0,0 +1,37 @@ +/** + * The audit ledger's contract: the file the audit tools write into the user's + * project, the check shape inside it, and the read-side coercion. Shared by + * the agent's ledger tools (both harnesses), the scanner's documentation + * allowlist and the audit program that watches the file. + */ + +export type AuditStatus = + | 'pending' + | 'pass' + | 'error' + | 'warning' + | 'suggestion'; + +export interface AuditCheck { + id: string; + area: string; + label: string; + status: AuditStatus; + file?: string; + details?: string; +} + +export const AUDIT_CHECKS_FILE = '.posthog-audit-checks.json'; +export const AUDIT_REPORT_FILE = 'posthog-audit-report.md'; + +/** + * Read the audit checks ledger off disk. Validation lives at write time — + * every writer (`audit_seed_checks` / `audit_add_checks` / `audit_resolve_checks` + * MCP tools, `seedAuditLedger`) zod-parses entries before the atomic write, + * so by the time the file watcher fires we trust the shape and only guard + * against the file not being a JSON array (corrupted / hand-edited / not yet + * seeded). + */ +export function coerceAuditChecks(parsed: unknown): AuditCheck[] { + return Array.isArray(parsed) ? (parsed as AuditCheck[]) : []; +} diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 1fd68c9c0..c055778ce 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -120,6 +120,18 @@ export const ADDITIONAL_FEATURE_PROMPTS: Record = { [AdditionalFeature.LLM]: `Now integrate AI observability with PostHog. Use the PostHog MCP server to find the appropriate AI observability skill, install it, and follow its workflow. PostHog basics are already installed. Update the setup report markdown file when complete with additions from this task. `, }; +// ── Documents the wizard's programs write into the user's project ──── +// Named here so the scanner's documentation allowlist can list them without +// importing a program; each program re-exports its own. +/** The events-audit report. */ +export const EVENTS_AUDIT_REPORT_FILE = 'posthog-events-audit-report.md'; +export const EVENT_INVENTORY_FILE = '.posthog-events-inventory.json'; +/** Per-part filename pattern emitted by events-audit subagents (e.g. `.posthog-events-inventory.part-3.json`). */ +export const EVENT_INVENTORY_PART_PATTERN = + /^\.posthog-events-inventory\.part-\d+\.json$/; +/** The integration program's event plan. */ +export const EVENT_PLAN_FILE = '.posthog-events.json'; + export interface Args { debug: boolean; integration: Integration; diff --git a/src/lib/programs/audit/types.ts b/src/lib/programs/audit/types.ts index dd858a709..0a705ce24 100644 --- a/src/lib/programs/audit/types.ts +++ b/src/lib/programs/audit/types.ts @@ -1,20 +1,16 @@ import type { WizardSession } from '@lib/wizard-session'; +import { + AUDIT_CHECKS_FILE, + AUDIT_REPORT_FILE, + coerceAuditChecks, + type AuditCheck, + type AuditStatus, +} from '@lib/audit-ledger'; -export type AuditStatus = - | 'pending' - | 'pass' - | 'error' - | 'warning' - | 'suggestion'; - -export interface AuditCheck { - id: string; - area: string; - label: string; - status: AuditStatus; - file?: string; - details?: string; -} +// The ledger contract lives in `@lib/audit-ledger`; re-exported so the audit +// views and the ledger watcher keep their import path. +export { AUDIT_CHECKS_FILE, AUDIT_REPORT_FILE, coerceAuditChecks }; +export type { AuditCheck, AuditStatus }; export interface AuditSeverityStyle { glyph: string; @@ -30,23 +26,9 @@ export const AUDIT_SEVERITY_STYLE: Record = { suggestion: { glyph: '•', color: 'cyan' }, }; -export const AUDIT_CHECKS_FILE = '.posthog-audit-checks.json'; -export const AUDIT_REPORT_FILE = 'posthog-audit-report.md'; export const AUDIT_CHECKS_KEY = 'auditChecks'; export function getAuditChecks(session: WizardSession): AuditCheck[] { const raw = session.frameworkContext[AUDIT_CHECKS_KEY]; return Array.isArray(raw) ? (raw as AuditCheck[]) : []; } - -/** - * Read the audit checks ledger off disk. Validation lives at write time — - * every writer (`audit_seed_checks` / `audit_add_checks` / `audit_resolve_checks` - * MCP tools, `seedAuditLedger`) zod-parses entries before the atomic write, - * so by the time the file watcher fires we trust the shape and only guard - * against the file not being a JSON array (corrupted / hand-edited / not yet - * seeded). - */ -export function coerceAuditChecks(parsed: unknown): AuditCheck[] { - return Array.isArray(parsed) ? (parsed as AuditCheck[]) : []; -} diff --git a/src/lib/programs/events-audit/constants.ts b/src/lib/programs/events-audit/constants.ts index 52842d0b5..a70c343df 100644 --- a/src/lib/programs/events-audit/constants.ts +++ b/src/lib/programs/events-audit/constants.ts @@ -1,13 +1,11 @@ /** - * Leaf-level constants for the events-audit program. - * - * Kept separate from `index.ts` so files like `yara-hooks.ts` can import - * the filename constants without dragging in `index.ts`'s heavier imports - * (agent-runner, audit/seed, etc.) — which can create import cycles. + * Leaf-level constants for the events-audit program. The document names live + * in `@lib/constants` so infrastructure (the scanner's allowlist) can name + * them without importing this program. */ -export const SETUP_REPORT_FILE = 'posthog-events-audit-report.md'; -export const EVENT_INVENTORY_FILE = '.posthog-events-inventory.json'; -/** Per-part filename pattern emitted by events-audit subagents (e.g. `.posthog-events-inventory.part-3.json`). */ -export const EVENT_INVENTORY_PART_PATTERN = - /^\.posthog-events-inventory\.part-\d+\.json$/; +export { + EVENTS_AUDIT_REPORT_FILE as SETUP_REPORT_FILE, + EVENT_INVENTORY_FILE, + EVENT_INVENTORY_PART_PATTERN, +} from '@lib/constants'; diff --git a/src/lib/programs/posthog-integration/constants.ts b/src/lib/programs/posthog-integration/constants.ts index 1f854850e..a0bb8d34b 100644 --- a/src/lib/programs/posthog-integration/constants.ts +++ b/src/lib/programs/posthog-integration/constants.ts @@ -1,10 +1,7 @@ /** - * Leaf-level constants for the posthog-integration program. - * - * Kept separate from `index.ts` so files like `yara-hooks.ts` can import - * the filename constants without dragging in `index.ts`'s heavier imports - * (agent-interface, framework-config, etc.) — which would create an import - * cycle through agent-interface → yara-hooks. + * Leaf-level constants for the posthog-integration program. The event plan's + * name lives in `@lib/constants` so infrastructure (the scanner's allowlist) + * can name it without importing this program. */ -export const EVENT_PLAN_FILE = '.posthog-events.json'; +export { EVENT_PLAN_FILE } from '@lib/constants'; diff --git a/src/lib/wizard-tools/mcp.ts b/src/lib/wizard-tools/mcp.ts index 4ad43e235..15fafd432 100644 --- a/src/lib/wizard-tools/mcp.ts +++ b/src/lib/wizard-tools/mcp.ts @@ -19,7 +19,7 @@ import { AUDIT_CHECKS_FILE, type AuditCheck, type AuditStatus, -} from '../programs/audit/types'; +} from '../audit-ledger'; import { type WizardAskBridge, isFullyCancelled } from '../wizard-ask-bridge'; import { PUBLISH_HANDOFF_CONTENT_DESCRIPTION, diff --git a/src/lib/wizard-tools/tools.ts b/src/lib/wizard-tools/tools.ts index 06b35c55c..134de05f5 100644 --- a/src/lib/wizard-tools/tools.ts +++ b/src/lib/wizard-tools/tools.ts @@ -28,7 +28,7 @@ import { coerceAuditChecks, type AuditCheck, type AuditStatus, -} from '../programs/audit/types'; +} from '../audit-ledger'; import { CANCELLED_SENTINEL } from '../wizard-ask-bridge'; import type { SecretVault } from '../secret-vault'; import { fetchWithRetry, type RetryOpts } from '../fetch-retry'; diff --git a/src/lib/yara-hooks.ts b/src/lib/yara-hooks.ts index 3ec2cb266..cd3d052f0 100644 --- a/src/lib/yara-hooks.ts +++ b/src/lib/yara-hooks.ts @@ -45,22 +45,18 @@ import { } from './yara-policy'; import type { ScanAction, ScanContext } from './yara-policy'; import { WIZARD_YARA_REPORT_FILE } from '@utils/paths'; -// TODO(wizard#594): invert this dependency. -// L2 infra (yara-hooks) imports product-specific filename constants from -// individual programs. The leaf `constants.ts` modules break the *import* -// cycle but don't fix the *layering* concern: this file knowing about -// `events-audit`, `posthog-integration`, and `audit` violates "product -// knowledge never enters infrastructure code." Proper fix is inverted — -// programs declare their own doc paths, the hooks read from a generic -// registry. For now: every new program emitting a PII-shaped report has -// to be added here. Land that cleanup before adding a fourth entry. +// TODO(wizard#594): invert this dependency. The document names are shared +// constants now, so nothing here imports a program, but this file still knows +// which programs write PII-shaped reports: every new one has to be added. +// Proper fix is inverted — programs declare their own doc paths and the hooks +// read a generic registry. Land that before adding a fourth entry. import { - SETUP_REPORT_FILE as EVENTS_AUDIT_REPORT_FILE, + EVENTS_AUDIT_REPORT_FILE, EVENT_INVENTORY_FILE, EVENT_INVENTORY_PART_PATTERN, -} from '@lib/programs/events-audit/constants'; -import { AUDIT_REPORT_FILE } from '@lib/programs/audit/types'; -import { EVENT_PLAN_FILE } from '@lib/programs/posthog-integration/constants'; + EVENT_PLAN_FILE, +} from '@lib/constants'; +import { AUDIT_REPORT_FILE } from '@lib/audit-ledger'; // ─── Warlock module accessor ───────────────────────────────────── // Warlock is ESM-only and lazily inits its WASM engine + compiles rules on the From a88c581611ae8367cb86df6a2e9132ee090e471e Mon Sep 17 00:00:00 2001 From: "Vincent (Wen Yu) Ge" Date: Mon, 21 Sep 2026 21:27:02 -0400 Subject: [PATCH 07/13] refactor(shared): move the Node package-manager detector next to its data PackageManagerDetector, PackageManagerInfo, DetectedPackageManager and detectNodePackageManagers move into @utils/package-manager, which already owns the lockfile detection they wrap. src/lib/detection/package-manager re-exports them, so every framework config is unchanged; the agent's two harnesses and the tools server now import the detector contract without importing detection. Generated-By: PostHog Desktop Task-Id: d14e92bb-6ee1-49b5-8502-39cb80079589 --- src/lib/agent/agent-interface.ts | 2 +- .../agent/runner/harness/anthropic/index.ts | 2 +- src/lib/agent/runner/harness/pi/tools.ts | 2 +- src/lib/agent/runner/shared/types.ts | 2 +- src/lib/detection/package-manager.ts | 92 ++++--------------- src/lib/wizard-tools/mcp.ts | 2 +- src/utils/package-manager.ts | 67 ++++++++++++++ 7 files changed, 91 insertions(+), 78 deletions(-) diff --git a/src/lib/agent/agent-interface.ts b/src/lib/agent/agent-interface.ts index 283420ad6..d4e9a3bcd 100644 --- a/src/lib/agent/agent-interface.ts +++ b/src/lib/agent/agent-interface.ts @@ -48,7 +48,7 @@ import { createTriageLLMProvider } from './triage-provider'; import type { LLMProvider } from '@posthog/warlock'; import { assembleCommandments } from './runner/switchboard/commandments'; import { classifyToolToStage } from './agent-phase'; -import type { PackageManagerDetector } from '@lib/detection/package-manager'; +import type { PackageManagerDetector } from '@utils/package-manager'; import { AgentSignals, AgentErrorType, diff --git a/src/lib/agent/runner/harness/anthropic/index.ts b/src/lib/agent/runner/harness/anthropic/index.ts index 660c5808e..1b967a662 100644 --- a/src/lib/agent/runner/harness/anthropic/index.ts +++ b/src/lib/agent/runner/harness/anthropic/index.ts @@ -7,7 +7,7 @@ import { } from '@lib/agent/agent-interface'; import { createAioCapture } from '@lib/agent/aio-capture'; import { getLogFilePath, logToFile } from '@utils/debug'; -import { detectNodePackageManagers } from '@lib/detection/package-manager'; +import { detectNodePackageManagers } from '@utils/package-manager'; import { runOptions } from '@lib/agent/runner/shared/bootstrap'; import { createEmitLog } from '@lib/agent/runner/shared/progress-collector'; import type { diff --git a/src/lib/agent/runner/harness/pi/tools.ts b/src/lib/agent/runner/harness/pi/tools.ts index 06121652c..360cf0df1 100644 --- a/src/lib/agent/runner/harness/pi/tools.ts +++ b/src/lib/agent/runner/harness/pi/tools.ts @@ -72,7 +72,7 @@ import { withMode } from './index'; import { detectNodePackageManagers, type PackageManagerDetector, -} from '@lib/detection/package-manager'; +} from '@utils/package-manager'; function text(s: string): { content: [{ type: 'text'; text: string }]; diff --git a/src/lib/agent/runner/shared/types.ts b/src/lib/agent/runner/shared/types.ts index 82df5779b..db73f8429 100644 --- a/src/lib/agent/runner/shared/types.ts +++ b/src/lib/agent/runner/shared/types.ts @@ -14,7 +14,7 @@ import type { CloudRegion } from '@utils/types'; import type { Credentials } from '@lib/api'; import type { OutroData, TaskNotice } from '@lib/agent/progress'; import type { PromptContext } from '@lib/agent/agent-prompt'; -import type { PackageManagerDetector } from '@lib/detection/package-manager'; +import type { PackageManagerDetector } from '@utils/package-manager'; import type { ApiProject, ApiUser } from '@lib/api'; import type { Harness, Integration, Sequence } from '@lib/constants'; import type { ErrorCode } from '@lib/errors'; diff --git a/src/lib/detection/package-manager.ts b/src/lib/detection/package-manager.ts index be50909fb..8212dd1f7 100644 --- a/src/lib/detection/package-manager.ts +++ b/src/lib/detection/package-manager.ts @@ -1,90 +1,36 @@ /** * Cross-ecosystem package manager detection. * - * Provides a common interface (PackageManagerDetector) that each FrameworkConfig - * implements, plus shared helpers for Node.js, Python, PHP, and Swift ecosystems. - * The MCP tool in wizard-tools.ts delegates to whatever detector the - * current framework supplies. + * Each FrameworkConfig implements the PackageManagerDetector contract; the + * helpers here cover the Python, PHP, Swift, Ruby, Rust, Elixir, Go, Flutter, + * Android and Java ecosystems (Node is in `@utils/package-manager`). The + * `detect_package_manager` tool delegates to whatever detector the current + * framework supplies. */ import * as fs from 'node:fs'; import * as path from 'node:path'; import { - detectAllPackageManagers, - type PackageManager, + detectNodePackageManagers, + type DetectedPackageManager, + type PackageManagerDetector, + type PackageManagerInfo, } from '@utils/package-manager'; + +// The detector contract and the Node detector live in `@utils/package-manager` +// (the agent's tools need them without importing detection); re-exported so +// every framework keeps its import path. +export { detectNodePackageManagers }; +export type { + DetectedPackageManager, + PackageManagerDetector, + PackageManagerInfo, +}; import { detectPackageManager as detectPythonPM, PythonPackageManager, } from '@frameworks/python/utils'; -// --------------------------------------------------------------------------- -// Common types -// --------------------------------------------------------------------------- - -/** Structured package manager info the agent can act on */ -export interface DetectedPackageManager { - name: string; - label: string; - installCommand: string; - runCommand?: string; -} - -/** Result returned by every detector */ -export interface PackageManagerInfo { - detected: DetectedPackageManager[]; - primary: DetectedPackageManager | null; - recommendation: string; -} - -/** Signature each framework implements */ -export type PackageManagerDetector = ( - installDir: string, -) => Promise; - -// --------------------------------------------------------------------------- -// Node.js helper -// --------------------------------------------------------------------------- - -function serializeNodePM(pm: PackageManager): DetectedPackageManager { - return { - name: pm.name, - label: pm.label, - installCommand: pm.installCommand, - runCommand: pm.runScriptCommand, - }; -} - -/** - * Detect Node.js package managers via lockfiles. - * Wraps the existing detectAllPackageManagers() from utils/package-manager.ts. - */ -export function detectNodePackageManagers( - installDir: string, -): Promise { - const detected = detectAllPackageManagers({ installDir }).map( - serializeNodePM, - ); - - if (detected.length === 0) { - return Promise.resolve({ - detected: [], - primary: null, - recommendation: 'No lockfile found. Default to npm (npm add, npm run).', - }); - } - - const primary = detected[0]; - return Promise.resolve({ - detected, - primary, - recommendation: - detected.length === 1 - ? `Use ${primary.label} (${primary.installCommand}).` - : `Multiple package managers detected. Prefer ${primary.label} (${primary.installCommand}).`, - }); -} - // --------------------------------------------------------------------------- // Python helper // --------------------------------------------------------------------------- diff --git a/src/lib/wizard-tools/mcp.ts b/src/lib/wizard-tools/mcp.ts index 15fafd432..c79510a9b 100644 --- a/src/lib/wizard-tools/mcp.ts +++ b/src/lib/wizard-tools/mcp.ts @@ -14,7 +14,7 @@ import { z } from 'zod'; import { logToFile } from '@utils/debug'; import { analytics } from '@utils/analytics'; import { makeMutex } from '@utils/atomic-ledger'; -import type { PackageManagerDetector } from '../detection/package-manager'; +import type { PackageManagerDetector } from '@utils/package-manager'; import { AUDIT_CHECKS_FILE, type AuditCheck, diff --git a/src/utils/package-manager.ts b/src/utils/package-manager.ts index 1339d602e..2c3790fda 100644 --- a/src/utils/package-manager.ts +++ b/src/utils/package-manager.ts @@ -170,3 +170,70 @@ export function detectAllPackageManagers({ return matches; }); } + +// --------------------------------------------------------------------------- +// The detector contract the agent's `detect_package_manager` tool calls +// --------------------------------------------------------------------------- + +/** Structured package manager info the agent can act on */ +export interface DetectedPackageManager { + name: string; + label: string; + installCommand: string; + runCommand?: string; +} + +/** Result returned by every detector */ +export interface PackageManagerInfo { + detected: DetectedPackageManager[]; + primary: DetectedPackageManager | null; + recommendation: string; +} + +/** Signature each framework implements */ +export type PackageManagerDetector = ( + installDir: string, +) => Promise; + +// --------------------------------------------------------------------------- +// Node.js helper +// --------------------------------------------------------------------------- + +function serializeNodePM(pm: PackageManager): DetectedPackageManager { + return { + name: pm.name, + label: pm.label, + installCommand: pm.installCommand, + runCommand: pm.runScriptCommand, + }; +} + +/** + * Detect Node.js package managers via lockfiles. + * Wraps detectAllPackageManagers() above. + */ +export function detectNodePackageManagers( + installDir: string, +): Promise { + const detected = detectAllPackageManagers({ installDir }).map( + serializeNodePM, + ); + + if (detected.length === 0) { + return Promise.resolve({ + detected: [], + primary: null, + recommendation: 'No lockfile found. Default to npm (npm add, npm run).', + }); + } + + const primary = detected[0]; + return Promise.resolve({ + detected, + primary, + recommendation: + detected.length === 1 + ? `Use ${primary.label} (${primary.installCommand}).` + : `Multiple package managers detected. Prefer ${primary.label} (${primary.installCommand}).`, + }); +} From 0b98bd370e9fc3d2e4f4cefa79e18065717648e1 Mon Sep 17 00:00:00 2001 From: "Vincent (Wen Yu) Ge" Date: Mon, 21 Sep 2026 21:29:25 -0400 Subject: [PATCH 08/13] refactor(middleware): report benchmark lines as progress events createBenchmarkPipeline takes the run's emitter; the summary and JSON writer plugins receive it through MiddlewareFactoryOptions and emit one `log` event per line they used to print through getUI(). The legacy adapter's reducer maps each back to WizardUI.log.info, so --benchmark output is unchanged. Nothing under src/lib/middleware imports src/ui. Generated-By: PostHog Desktop Task-Id: d14e92bb-6ee1-49b5-8502-39cb80079589 --- src/lib/agent/runner/sequence/linear.ts | 2 +- .../__tests__/benchmark-emit.test.ts | 104 ++++++++++++++++++ src/lib/middleware/benchmark.ts | 15 +-- src/lib/middleware/benchmarks/index.ts | 4 +- src/lib/middleware/benchmarks/json-writer.ts | 8 +- src/lib/middleware/benchmarks/summary.ts | 24 ++-- src/lib/middleware/types.ts | 4 +- 7 files changed, 136 insertions(+), 25 deletions(-) create mode 100644 src/lib/middleware/__tests__/benchmark-emit.test.ts diff --git a/src/lib/agent/runner/sequence/linear.ts b/src/lib/agent/runner/sequence/linear.ts index 8fe2a8df9..e45358fd1 100644 --- a/src/lib/agent/runner/sequence/linear.ts +++ b/src/lib/agent/runner/sequence/linear.ts @@ -75,7 +75,7 @@ export async function runLinearProgram({ }); const middleware = input.flags.benchmark - ? createBenchmarkPipeline(spinner, runOptions(input)) + ? createBenchmarkPipeline(emit, spinner, runOptions(input)) : undefined; // 7. Build prompt diff --git a/src/lib/middleware/__tests__/benchmark-emit.test.ts b/src/lib/middleware/__tests__/benchmark-emit.test.ts new file mode 100644 index 000000000..2b1979e68 --- /dev/null +++ b/src/lib/middleware/__tests__/benchmark-emit.test.ts @@ -0,0 +1,104 @@ +import { existsSync, mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import type { AgentProgress } from '@lib/agent/progress'; + +// The benchmark pipeline runs inside the agent, which has no UI. Reaching one +// is the defect this file guards against. +vi.mock('@ui', () => ({ + getUI: () => { + throw new Error('agent code reached the UI'); + }, +})); +vi.mock('@utils/debug', () => ({ + logToFile: vi.fn(), + configureLogFile: vi.fn(), + getLogFilePath: () => '/tmp/wizard.log', +})); + +import { createBenchmarkPipeline } from '../benchmark'; +import { getDefaultConfig } from '../config'; + +describe('createBenchmarkPipeline', () => { + let dir: string; + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'wizard-benchmark-')); + }); + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + it('reports every benchmark line as a progress event and never touches the UI', () => { + const events: AgentProgress[] = []; + const spinner = { start: vi.fn(), stop: vi.fn(), message: vi.fn() }; + const config = getDefaultConfig(); + config.output.benchmarkPath = join(dir, 'benchmark.json'); + config.output.logPath = join(dir, 'wizard.log'); + config.output.logEnabled = false; + + const pipeline = createBenchmarkPipeline( + (event) => events.push(event), + spinner, + { + installDir: dir, + ci: false, + debug: false, + benchmark: true, + yaraReport: false, + signup: false, + }, + config, + ); + pipeline.onMessage({ + type: 'assistant', + message: { role: 'assistant', content: [{ type: 'text', text: 'hi' }] }, + }); + pipeline.finalize({ type: 'result', modelUsage: {}, num_turns: 1 }, 1500); + + const logs = events.flatMap((event) => + event.kind === 'log' ? [event.message] : [], + ); + expect(logs[0]).toContain('Verbose logs: /tmp/wizard.log'); + expect(logs[1]).toContain( + `Benchmark data will be written to: ${config.output.benchmarkPath}`, + ); + expect(logs.some((line) => line.includes('Summary by phase'))).toBe(true); + expect(logs.at(-1)).toContain( + `Results written to ${config.output.benchmarkPath}`, + ); + expect(events.every((event) => event.kind === 'log')).toBe(true); + expect(existsSync(config.output.benchmarkPath)).toBe(true); + }); + + it('stays quiet when the config suppresses wizard logs', () => { + const events: AgentProgress[] = []; + const spinner = { start: vi.fn(), stop: vi.fn(), message: vi.fn() }; + const config = getDefaultConfig(); + config.output.benchmarkPath = join(dir, 'benchmark.json'); + config.output.logPath = join(dir, 'wizard.log'); + config.output.logEnabled = false; + config.output.suppressWizardLogs = true; + + const pipeline = createBenchmarkPipeline( + (event) => events.push(event), + spinner, + { + installDir: dir, + ci: false, + debug: false, + benchmark: true, + yaraReport: false, + signup: false, + }, + config, + ); + pipeline.finalize({ type: 'result', modelUsage: {} }, 10); + + const logs = events.flatMap((event) => + event.kind === 'log' ? [event.message] : [], + ); + // Only the JSON writer speaks; the summary plugin is disabled by the flag. + expect(logs).toHaveLength(1); + expect(logs[0]).toContain('Results written to'); + }); +}); diff --git a/src/lib/middleware/benchmark.ts b/src/lib/middleware/benchmark.ts index bc9fc3e7b..08c044253 100644 --- a/src/lib/middleware/benchmark.ts +++ b/src/lib/middleware/benchmark.ts @@ -2,13 +2,12 @@ * Benchmark tracking for wizard runs. * * Usage: - * const pipeline = createBenchmarkPipeline(spinner, options); + * const pipeline = createBenchmarkPipeline(emit, spinner, options); * pipeline.onMessage(message); * pipeline.finalize(resultMessage, durationMs); */ -import { getUI } from '@ui'; -import type { SpinnerHandle } from '@lib/agent/progress'; +import type { ProgressEmitter, SpinnerHandle } from '@lib/agent/progress'; import { logToFile, getLogFilePath, configureLogFile } from '@utils/debug'; import { MiddlewarePipeline } from './pipeline'; import { PhaseDetector } from './phase-detector'; @@ -66,11 +65,14 @@ export interface BenchmarkData { * Loads .benchmark-config.json from the install dir, falls back to defaults. */ export function createBenchmarkPipeline( + emit: ProgressEmitter, spinner: SpinnerHandle, options: WizardRunOptions, configOverride?: BenchmarkConfig, ): MiddlewarePipeline { const config = configOverride ?? loadBenchmarkConfig(options.installDir); + const info = (message: string) => + emit({ kind: 'log', level: 'info', message }); configureLogFile({ path: config.output.logPath, @@ -78,16 +80,15 @@ export function createBenchmarkPipeline( }); const plugins = createPluginsFromConfig(config, { + emit, spinner, phased: false, outputPath: config.output.benchmarkPath, }); if (!config.output.suppressWizardLogs) { - getUI().log.info( - `${AgentSignals.BENCHMARK} Verbose logs: ${getLogFilePath()}`, - ); - getUI().log.info( + info(`${AgentSignals.BENCHMARK} Verbose logs: ${getLogFilePath()}`); + info( `${AgentSignals.BENCHMARK} Benchmark data will be written to: ${config.output.benchmarkPath}`, ); } diff --git a/src/lib/middleware/benchmarks/index.ts b/src/lib/middleware/benchmarks/index.ts index 470dbd82b..33491a8db 100644 --- a/src/lib/middleware/benchmarks/index.ts +++ b/src/lib/middleware/benchmarks/index.ts @@ -30,8 +30,8 @@ const PLUGIN_REGISTRY: Record = { contextSize: () => new ContextSizeTrackerPlugin(), cost: () => new CostTrackerPlugin(), duration: () => new DurationTrackerPlugin(), - summary: (opts) => new SummaryPlugin(opts.spinner!), - jsonWriter: (opts) => new JsonWriterPlugin(opts.outputPath!), + summary: (opts) => new SummaryPlugin(opts.spinner!, opts.emit), + jsonWriter: (opts) => new JsonWriterPlugin(opts.outputPath!, opts.emit), }; /** diff --git a/src/lib/middleware/benchmarks/json-writer.ts b/src/lib/middleware/benchmarks/json-writer.ts index 253225335..a68cad9c6 100644 --- a/src/lib/middleware/benchmarks/json-writer.ts +++ b/src/lib/middleware/benchmarks/json-writer.ts @@ -6,7 +6,7 @@ */ import fs from 'fs'; -import { getUI } from '@ui'; +import type { ProgressEmitter } from '@lib/agent/progress'; import { logToFile } from '@utils/debug'; import { AgentSignals } from '@lib/agent/agent-interface'; import type { @@ -56,9 +56,11 @@ export class JsonWriterPlugin implements Middleware { readonly name = 'jsonWriter'; private outputPath: string; + private readonly info: (message: string) => void; - constructor(outputPath: string) { + constructor(outputPath: string, emit: ProgressEmitter) { this.outputPath = outputPath; + this.info = (message) => emit({ kind: 'log', level: 'info', message }); } onFinalize( @@ -170,7 +172,7 @@ export class JsonWriterPlugin implements Middleware { try { fs.writeFileSync(this.outputPath, JSON.stringify(data, null, 2)); logToFile(`Benchmark data written to ${this.outputPath}`); - getUI().log.info( + this.info( `● ${AgentSignals.BENCHMARK} Results written to ${this.outputPath}`, ); } catch (error) { diff --git a/src/lib/middleware/benchmarks/summary.ts b/src/lib/middleware/benchmarks/summary.ts index 1900f9cc5..be1369561 100644 --- a/src/lib/middleware/benchmarks/summary.ts +++ b/src/lib/middleware/benchmarks/summary.ts @@ -1,5 +1,4 @@ -import { getUI } from '@ui'; -import type { SpinnerHandle } from '@lib/agent/progress'; +import type { ProgressEmitter, SpinnerHandle } from '@lib/agent/progress'; import { AgentSignals } from '@lib/agent/agent-interface'; import type { Middleware, @@ -98,8 +97,11 @@ export class SummaryPlugin implements Middleware { private spinner: SpinnerHandle; - constructor(spinner: SpinnerHandle) { + private readonly info: (message: string) => void; + + constructor(spinner: SpinnerHandle, emit: ProgressEmitter) { this.spinner = spinner; + this.info = (message) => emit({ kind: 'log', level: 'info', message }); } onPhaseTransition( @@ -118,7 +120,7 @@ export class SummaryPlugin implements Middleware { this.spinner.stop(`${AgentSignals.BENCHMARK} ${fromPhase}`); } - getUI().log.info(`${AgentSignals.BENCHMARK} Starting phase: ${toPhase}`); + this.info(`${AgentSignals.BENCHMARK} Starting phase: ${toPhase}`); this.spinner.start(`Integrating PostHog (${toPhase})...`); } @@ -136,31 +138,31 @@ export class SummaryPlugin implements Middleware { const phaseCount = duration?.phaseSnapshots.length ?? 0; const totalCost = cost?.totalCost ?? 0; - getUI().log.info(''); - getUI().log.info( + this.info(''); + this.info( `◇ ${AgentSignals.BENCHMARK} ${phaseCount} phases in ${fmtDuration( totalDurationMs, )}, cost: ${fmtCost(totalCost)}`, ); - getUI().log.info( + this.info( ` total in: ${fmtTok(tokens?.totalInput ?? 0)}, out: ${fmtTok( tokens?.totalOutput ?? 0, )}, cache_read: ${fmtTok(cache?.totalRead ?? 0)}, cache_5m: ${fmtTok( cache?.totalCreation5m ?? 0, )}, cache_1h: ${fmtTok(cache?.totalCreation1h ?? 0)}`, ); - getUI().log.info(''); - getUI().log.info(`● ${AgentSignals.BENCHMARK} Summary by phase:`); + this.info(''); + this.info(`● ${AgentSignals.BENCHMARK} Summary by phase:`); if (duration?.phaseSnapshots) { for (let i = 0; i < duration.phaseSnapshots.length; i++) { const stats = getPhaseStats(i, ctx); if (stats) { - getUI().log.info(printPhase(stats)); + this.info(printPhase(stats)); } } } - getUI().log.info(''); + this.info(''); } } diff --git a/src/lib/middleware/types.ts b/src/lib/middleware/types.ts index 9e84580e0..8ccd15ec9 100644 --- a/src/lib/middleware/types.ts +++ b/src/lib/middleware/types.ts @@ -5,7 +5,7 @@ * and can publish data to a shared store for downstream middleware to read. */ -import type { SpinnerHandle } from '@lib/agent/progress'; +import type { ProgressEmitter, SpinnerHandle } from '@lib/agent/progress'; export type SDKMessage = any; @@ -54,6 +54,8 @@ export interface Middleware { /** Options bag passed to middleware factories during construction */ export interface MiddlewareFactoryOptions { + /** Where a plugin's user-facing lines go: the run's progress events. */ + emit: ProgressEmitter; spinner?: SpinnerHandle; outputPath?: string; phased?: boolean; From 856b613da0a56fff5fa5fee478f9370e481c31fd Mon Sep 17 00:00:00 2001 From: "Vincent (Wen Yu) Ge" Date: Mon, 21 Sep 2026 21:30:22 -0400 Subject: [PATCH 09/13] refactor(yara): return the scan report line instead of printing it flushScanReport takes `{ yaraReport }` and returns the report line when it wrote one. The runner emits it as a `log` progress event from its single flush seam; the legacy adapter's cleanup prints it through the UI as before. yara-hooks no longer imports the UI or the session type. Generated-By: PostHog Desktop Task-Id: d14e92bb-6ee1-49b5-8502-39cb80079589 --- src/lib/__tests__/yara-flush-report.test.ts | 58 +++++++++++++++++++ .../__tests__/run-agent-standalone.test.ts | 1 + src/lib/agent/runner/index.ts | 3 +- src/lib/programs/run-agent-legacy.ts | 5 +- src/lib/yara-hooks.ts | 17 +++--- 5 files changed, 75 insertions(+), 9 deletions(-) create mode 100644 src/lib/__tests__/yara-flush-report.test.ts diff --git a/src/lib/__tests__/yara-flush-report.test.ts b/src/lib/__tests__/yara-flush-report.test.ts new file mode 100644 index 000000000..06e46e5c3 --- /dev/null +++ b/src/lib/__tests__/yara-flush-report.test.ts @@ -0,0 +1,58 @@ +import fs from 'fs'; +import { WIZARD_YARA_REPORT_FILE } from '@utils/paths'; + +// The flush runs inside the agent, which has no UI: the report line is +// returned to the caller, who decides where it goes. +vi.mock('@ui', () => ({ + getUI: () => { + throw new Error('agent code reached the UI'); + }, +})); +vi.mock('@utils/debug'); +vi.mock('@utils/analytics', () => ({ + analytics: { wizardCapture: vi.fn(), captureException: vi.fn() }, +})); +vi.mock('fs', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + default: { ...actual, writeFileSync: vi.fn() }, + writeFileSync: vi.fn(), + }; +}); + +import { + flushScanReport, + recordExternalScan, + resetScanReport, +} from '@lib/yara-hooks'; + +describe('flushScanReport', () => { + beforeEach(() => { + resetScanReport(); + vi.mocked(fs.writeFileSync).mockClear(); + }); + + it('returns the report line once when a report was requested', () => { + recordExternalScan('PostToolUse', 'Write', [], 'warned'); + + const line = flushScanReport({ yaraReport: true }); + + expect(line).toContain(`YARA scan report: ${WIZARD_YARA_REPORT_FILE}`); + expect(line).toContain('1 tool calls scanned, 0 violations detected'); + expect(fs.writeFileSync).toHaveBeenCalledWith( + WIZARD_YARA_REPORT_FILE, + expect.stringContaining('"totalScans": 1'), + ); + // Idempotent: the first flush zeroed the scan state. + expect(flushScanReport({ yaraReport: true })).toBeUndefined(); + }); + + it('returns nothing without --yara-report, but still flushes the state', () => { + recordExternalScan('PostToolUse', 'Write', [], 'warned'); + + expect(flushScanReport({ yaraReport: false })).toBeUndefined(); + expect(fs.writeFileSync).not.toHaveBeenCalled(); + expect(flushScanReport({ yaraReport: true })).toBeUndefined(); + }); +}); diff --git a/src/lib/agent/__tests__/run-agent-standalone.test.ts b/src/lib/agent/__tests__/run-agent-standalone.test.ts index 481e8a5eb..c419a2e48 100644 --- a/src/lib/agent/__tests__/run-agent-standalone.test.ts +++ b/src/lib/agent/__tests__/run-agent-standalone.test.ts @@ -443,6 +443,7 @@ describe('runAgent standalone', () => { }); vi.mocked(flushScanReport).mockImplementationOnce(() => { order.push('scan-flush'); + return undefined; }); const result = await runAgent( config({ diff --git a/src/lib/agent/runner/index.ts b/src/lib/agent/runner/index.ts index 70f213775..979a19723 100644 --- a/src/lib/agent/runner/index.ts +++ b/src/lib/agent/runner/index.ts @@ -123,6 +123,7 @@ export async function runAgent( snapshot: collector.snapshot(), }; } finally { - flushScanReport({ yaraReport: input.flags.yaraReport }); + const report = flushScanReport({ yaraReport: input.flags.yaraReport }); + if (report) log(report); } } diff --git a/src/lib/programs/run-agent-legacy.ts b/src/lib/programs/run-agent-legacy.ts index 87b8f8e36..f26bc571b 100644 --- a/src/lib/programs/run-agent-legacy.ts +++ b/src/lib/programs/run-agent-legacy.ts @@ -198,7 +198,10 @@ async function runProgram( // Cleanup coverage for the abort/cancel path: `wizardAbort` runs the // registered cleanups, and the agent's own `finally` covers completion. // flushScanReport is idempotent, so the overlap is a harmless no-op. - registerCleanup(() => flushScanReport({ yaraReport: session.yaraReport })); + registerCleanup(() => { + const report = flushScanReport({ yaraReport: session.yaraReport }); + if (report) ui.log.info(report); + }); // Linear settings restoration fires on entry to the outro screen, so it // is registered before the run can reach that screen. Same owner, same diff --git a/src/lib/yara-hooks.ts b/src/lib/yara-hooks.ts index cd3d052f0..6f97de2ac 100644 --- a/src/lib/yara-hooks.ts +++ b/src/lib/yara-hooks.ts @@ -35,8 +35,6 @@ import type { import { logToFile } from '@utils/debug'; import { readFileHead } from '@utils/bounded-fs'; import { analytics } from '@utils/analytics'; -import { getUI } from '@ui'; -import type { WizardSession } from '@lib/wizard-session'; import { isSkillInstallCommand } from './skill-install'; import { highestSeverityMatch, @@ -425,18 +423,23 @@ export function captureScanReport(): void { * which is what makes this whole function idempotent — a second call from * another termination path (e.g. finally after an abort already flushed) finds * scanCount === 0 and every step no-ops. + * + * Returns the user-facing report line when a report was written; the caller + * decides where it goes (the runner emits it as progress). */ -export function flushScanReport( - session: Pick, -): void { - if (session.yaraReport) { +export function flushScanReport(options: { + yaraReport: boolean; +}): string | undefined { + let line: string | undefined; + if (options.yaraReport) { const reportPath = writeScanReport(); if (reportPath) { const summary = formatScanReport(); - getUI().log.info(`YARA scan report: ${reportPath}${summary ?? ''}`); + line = `YARA scan report: ${reportPath}${summary ?? ''}`; } } captureScanReport(); + return line; } // ─── Wizard-documentation allowlist ─────────────────────────────── From 13fa0a2882e197f7448ed82534a86ee392c4c024 Mon Sep 17 00:00:00 2001 From: "Vincent (Wen Yu) Ge" Date: Mon, 21 Sep 2026 21:33:12 -0400 Subject: [PATCH 10/13] refactor(agent): publish the handoff as a progress event publish_handoff emits `{ kind: 'handoff', text }` instead of calling getUI().setHandoffText. Both facades (the MCP server and the pi tools) receive the run's emitter, the snapshot keeps the text as RunSnapshot.handoffText, and the legacy adapter's reducer maps the event to WizardUI.setHandoffText so the TUI and the headless host see exactly what they saw before. Generated-By: PostHog Desktop Task-Id: d14e92bb-6ee1-49b5-8502-39cb80079589 --- .../architecture/known-violations.json | 1 + .../__tests__/progress-collector.test.ts | 8 ++++ src/lib/agent/agent-interface.ts | 1 + src/lib/agent/progress.ts | 2 + src/lib/agent/runner/harness/pi/index.ts | 1 + src/lib/agent/runner/harness/pi/task.ts | 3 +- src/lib/agent/runner/harness/pi/tools.ts | 7 ++- .../agent/runner/shared/progress-collector.ts | 3 ++ src/lib/agent/runner/shared/types.ts | 2 + .../wizard-tools/__tests__/handoff.test.ts | 47 ++++++++++--------- src/lib/wizard-tools/handoff.ts | 10 ++-- src/lib/wizard-tools/mcp.ts | 7 ++- src/ui/__tests__/agent-progress.test.ts | 3 ++ src/ui/agent-progress.ts | 3 ++ 14 files changed, 68 insertions(+), 30 deletions(-) diff --git a/src/__tests__/architecture/known-violations.json b/src/__tests__/architecture/known-violations.json index d270a05bb..cac070705 100644 --- a/src/__tests__/architecture/known-violations.json +++ b/src/__tests__/architecture/known-violations.json @@ -49,6 +49,7 @@ "src/lib/wizard-ask-bridge.ts -> src/lib/agent/progress.ts", "src/lib/wizard-session.ts -> src/lib/agent/claude-settings.ts", "src/lib/wizard-session.ts -> src/lib/agent/progress.ts", + "src/lib/wizard-tools/handoff.ts -> src/lib/agent/progress.ts", "src/lib/wizard-tools/index.ts -> src/lib/wizard-tools/mcp.ts", "src/lib/wizard-tools/tools.ts -> src/lib/yara-hooks.ts", "src/steps/add-mcp-server-to-clients/index.ts -> src/telemetry.ts", diff --git a/src/lib/agent/__tests__/progress-collector.test.ts b/src/lib/agent/__tests__/progress-collector.test.ts index 061292550..1d6e0740f 100644 --- a/src/lib/agent/__tests__/progress-collector.test.ts +++ b/src/lib/agent/__tests__/progress-collector.test.ts @@ -37,3 +37,11 @@ it('isolates nested completion data from a mutating observer', () => { nextSteps: { heading: 'Next', items: ['Keep the report'] }, }); }); + +it('keeps the published handoff text in the snapshot', () => { + const collector = createProgressCollector(); + expect(collector.snapshot().handoffText).toBeUndefined(); + collector.emit({ kind: 'handoff', text: '# Report' }); + collector.emit({ kind: 'handoff', text: '# Report, revised' }); + expect(collector.snapshot().handoffText).toBe('# Report, revised'); +}); diff --git a/src/lib/agent/agent-interface.ts b/src/lib/agent/agent-interface.ts index d4e9a3bcd..2b47993ca 100644 --- a/src/lib/agent/agent-interface.ts +++ b/src/lib/agent/agent-interface.ts @@ -644,6 +644,7 @@ export async function initializeAgent( askMaxQuestions: config.askMaxQuestions, orchestrator: config.orchestrator, triageProvider, + emit: config.emit, }); mcpServers['wizard-tools'] = wizardToolsServer; diff --git a/src/lib/agent/progress.ts b/src/lib/agent/progress.ts index eb5386ff4..3bc45c79e 100644 --- a/src/lib/agent/progress.ts +++ b/src/lib/agent/progress.ts @@ -229,6 +229,8 @@ export type AgentProgress = | { kind: 'finalCost'; usd: number } /** The gateway returned 401; a failure follows (`WizardUI.showAuthError`). */ | { kind: 'authError'; detail: AuthErrorDetail } + /** The handoff document the agent published (`WizardUI.setHandoffText`). */ + | { kind: 'handoff'; text: string } /** The run's final outro payload (`WizardUI.setOutroData`). */ | { kind: 'completion'; outro: OutroData }; diff --git a/src/lib/agent/runner/harness/pi/index.ts b/src/lib/agent/runner/harness/pi/index.ts index 82c705615..b14df2a37 100644 --- a/src/lib/agent/runner/harness/pi/index.ts +++ b/src/lib/agent/runner/harness/pi/index.ts @@ -434,6 +434,7 @@ export const piBackend: AgentHarness = { workingDirectory: input.installDir, skillsBaseUrl: boot.skillsBaseUrl, triageProvider: boot.triageProvider, + emit, detectPackageManager: config.detectPackageManager, // The host ask bridge — lets interactive programs (self-driving) ask // the user through pi. Threaded from the runner, same path as the diff --git a/src/lib/agent/runner/harness/pi/task.ts b/src/lib/agent/runner/harness/pi/task.ts index 9d7adc97c..af9017a21 100644 --- a/src/lib/agent/runner/harness/pi/task.ts +++ b/src/lib/agent/runner/harness/pi/task.ts @@ -361,13 +361,14 @@ export async function runPiTask(inputs: TaskRunInputs): Promise { // Wizard env + package-manager tools are always on — their handlers are // fenced, and init/build tasks depend on them. publish_handoff rides - // along (store-only handler) so the report task can publish the handoff. + // along (it only emits) so the report task can publish the handoff. const { createWizardPiTools } = await import('./tools'); const wizardToolNames = allowedPiWizardTools(allowedTools); const wizardTools = createWizardPiTools({ workingDirectory: dir, skillsBaseUrl: boot.skillsBaseUrl, triageProvider: boot.triageProvider, + emit, // Present only for a task allowed to ask; without it wizard_ask errors // instead of hanging on a prompt nobody will ever see. askBridge, diff --git a/src/lib/agent/runner/harness/pi/tools.ts b/src/lib/agent/runner/harness/pi/tools.ts index 360cf0df1..117d7c349 100644 --- a/src/lib/agent/runner/harness/pi/tools.ts +++ b/src/lib/agent/runner/harness/pi/tools.ts @@ -54,6 +54,7 @@ import { WIZARD_ASK_TOOL_DESCRIPTION, } from '@lib/wizard-tools/tools'; import type { LLMProvider } from '@posthog/warlock'; +import type { ProgressEmitter } from '@lib/agent/progress'; import { isFullyCancelled, type WizardAskBridge } from '@lib/wizard-ask-bridge'; import { PUBLISH_HANDOFF_CONTENT_DESCRIPTION, @@ -96,6 +97,8 @@ export interface PiToolsContext { disallowedTools?: readonly string[]; /** Scan-triage classifier, resolved once in bootstrap. Absent → scans fail closed. */ triageProvider?: LLMProvider; + /** Where `publish_handoff` reports. Absent → the handoff is written but reported nowhere. */ + emit?: ProgressEmitter; } export function createWizardPiTools(ctx: PiToolsContext): ToolDefinition[] { @@ -556,7 +559,7 @@ export function createWizardPiTools(ctx: PiToolsContext): ToolDefinition[] { }), }), execute(_id, args) { - const result = publishHandoff(args.content); + const result = publishHandoff(args.content, ctx.emit); logToFile(`[pi] publish_handoff: ${result.message}`); return Promise.resolve(text(result.message)); }, @@ -572,7 +575,7 @@ export function createWizardPiTools(ctx: PiToolsContext): ToolDefinition[] { withMode(auditSeedChecks, 'parallel'), withMode(auditAddChecks, 'parallel'), withMode(auditResolveChecks, 'parallel'), - // Sequential: it mutates the store's handoff state. + // Sequential: it publishes the run's handoff. withMode(publishHandoffTool, 'sequential'), ]; // Register wizard_ask only when the program allows it. posthog-integration diff --git a/src/lib/agent/runner/shared/progress-collector.ts b/src/lib/agent/runner/shared/progress-collector.ts index 6f493dbc3..42a9da5f8 100644 --- a/src/lib/agent/runner/shared/progress-collector.ts +++ b/src/lib/agent/runner/shared/progress-collector.ts @@ -63,6 +63,9 @@ export function createProgressCollector( case 'finalCost': snapshot.finalCostUsd = event.usd; break; + case 'handoff': + snapshot.handoffText = event.text; + break; default: break; } diff --git a/src/lib/agent/runner/shared/types.ts b/src/lib/agent/runner/shared/types.ts index db73f8429..ea339af3e 100644 --- a/src/lib/agent/runner/shared/types.ts +++ b/src/lib/agent/runner/shared/types.ts @@ -279,6 +279,8 @@ export interface RunSnapshot { finalCostUsd?: number; dashboardUrl?: string; notebookUrl?: string; + /** The handoff document the agent published, when it did. */ + handoffText?: string; } /** A sequence decides an outcome; the dispatcher owns its snapshot. */ diff --git a/src/lib/wizard-tools/__tests__/handoff.test.ts b/src/lib/wizard-tools/__tests__/handoff.test.ts index bbe2aae14..c93b792c8 100644 --- a/src/lib/wizard-tools/__tests__/handoff.test.ts +++ b/src/lib/wizard-tools/__tests__/handoff.test.ts @@ -11,34 +11,35 @@ import { } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { getUI, setUI } from '@ui'; -import type { WizardUI } from '@ui/wizard-ui'; +import type { AgentProgress } from '@lib/agent/progress'; import { MAX_HANDOFF_TEXT_CHARS, publishHandoff } from '../handoff'; +// The tool runs inside the agent, which has no UI; the handoff is a progress +// event the host projects however it likes. +vi.mock('@ui', () => ({ + getUI: () => { + throw new Error('agent code reached the UI'); + }, +})); + describe('publishHandoff', () => { const captured: string[] = []; - let previousUI: WizardUI; + const emit = (event: AgentProgress) => { + if (event.kind === 'handoff') captured.push(event.text); + }; let temporaryDirectory: string; let ambientOutputPath: string | undefined; beforeEach(() => { captured.length = 0; - previousUI = getUI(); temporaryDirectory = mkdtempSync(join(tmpdir(), 'wizard-handoff-')); // Save the ambient value so a developer running these tests with the var // exported doesn't have their environment silently clobbered. ambientOutputPath = process.env.POSTHOG_HANDOFF_OUTPUT_PATH; delete process.env.POSTHOG_HANDOFF_OUTPUT_PATH; - setUI({ - ...previousUI, - setHandoffText: (text: string) => { - captured.push(text); - }, - } as WizardUI); }); afterEach(() => { - setUI(previousUI); rmSync(temporaryDirectory, { recursive: true, force: true }); if (ambientOutputPath === undefined) { delete process.env.POSTHOG_HANDOFF_OUTPUT_PATH; @@ -47,15 +48,15 @@ describe('publishHandoff', () => { } }); - it('publishes the content through the UI seam', () => { - const result = publishHandoff('# Setup report\n\nAll done.'); + it('publishes the content as a handoff progress event', () => { + const result = publishHandoff('# Setup report\n\nAll done.', emit); expect(result.ok).toBe(true); expect(captured).toEqual(['# Setup report\n\nAll done.']); }); it('rejects blank content instead of publishing', () => { for (const bad of ['', ' \n']) { - const result = publishHandoff(bad); + const result = publishHandoff(bad, emit); expect(result.ok).toBe(false); expect(result.message).toContain('complete report markdown'); } @@ -66,7 +67,7 @@ describe('publishHandoff', () => { const outputPath = join(temporaryDirectory, 'handoff.md'); process.env.POSTHOG_HANDOFF_OUTPUT_PATH = outputPath; const oversized = 'x'.repeat(MAX_HANDOFF_TEXT_CHARS + 10); - const result = publishHandoff(oversized); + const result = publishHandoff(oversized, emit); expect(result.ok).toBe(true); expect(captured[0]).toHaveLength(MAX_HANDOFF_TEXT_CHARS); @@ -80,7 +81,7 @@ describe('publishHandoff', () => { const outputPath = join(temporaryDirectory, 'handoff.md'); process.env.POSTHOG_HANDOFF_OUTPUT_PATH = outputPath; - const result = publishHandoff('# Setup report\n\nAll done.'); + const result = publishHandoff('# Setup report\n\nAll done.', emit); expect(result.ok).toBe(true); expect(readFileSync(outputPath, 'utf8')).toBe( @@ -91,7 +92,7 @@ describe('publishHandoff', () => { it('continues publishing when the host-provided output path cannot be written', () => { process.env.POSTHOG_HANDOFF_OUTPUT_PATH = temporaryDirectory; - const result = publishHandoff('# Setup report\n\nAll done.'); + const result = publishHandoff('# Setup report\n\nAll done.', emit); expect(result.ok).toBe(true); expect(captured).toEqual(['# Setup report\n\nAll done.']); @@ -103,7 +104,7 @@ describe('publishHandoff', () => { const outputPath = join(temporaryDirectory, 'handoff.md'); process.env.POSTHOG_HANDOFF_OUTPUT_PATH = outputPath; - publishHandoff('# Setup report\n\nAll done.'); + publishHandoff('# Setup report\n\nAll done.', emit); expect(statSync(outputPath).mode & 0o777).toBe(0o600); }); @@ -114,7 +115,7 @@ describe('publishHandoff', () => { chmodSync(outputPath, 0o644); // umask may already restrict; be explicit process.env.POSTHOG_HANDOFF_OUTPUT_PATH = outputPath; - publishHandoff('# Setup report\n\nAll done.'); + publishHandoff('# Setup report\n\nAll done.', emit); expect(readFileSync(outputPath, 'utf8')).toBe( '# Setup report\n\nAll done.', @@ -129,7 +130,7 @@ describe('publishHandoff', () => { symlinkSync(victim, outputPath); process.env.POSTHOG_HANDOFF_OUTPUT_PATH = outputPath; - const result = publishHandoff('# Malicious report'); + const result = publishHandoff('# Malicious report', emit); // The user-facing handoff still succeeds (fail-open)… expect(result.ok).toBe(true); @@ -148,7 +149,7 @@ describe('publishHandoff', () => { it('refuses a relative output path and stays fail-open', () => { process.env.POSTHOG_HANDOFF_OUTPUT_PATH = 'handoff.md'; - const result = publishHandoff('# Setup report\n\nAll done.'); + const result = publishHandoff('# Setup report\n\nAll done.', emit); expect(result.ok).toBe(true); expect(captured).toEqual(['# Setup report\n\nAll done.']); @@ -159,7 +160,7 @@ describe('publishHandoff', () => { const outputPath = join(temporaryDirectory, 'handoff.md'); process.env.POSTHOG_HANDOFF_OUTPUT_PATH = outputPath; - publishHandoff('# Setup report\n\nAll done.'); + publishHandoff('# Setup report\n\nAll done.', emit); expect(readdirSync(temporaryDirectory)).toEqual(['handoff.md']); }); @@ -169,7 +170,7 @@ describe('publishHandoff', () => { writeFileSync(outputPath, '# Previous run\n\nStale content.', 'utf8'); process.env.POSTHOG_HANDOFF_OUTPUT_PATH = outputPath; - publishHandoff('# Current run\n\nFresh content.'); + publishHandoff('# Current run\n\nFresh content.', emit); expect(readFileSync(outputPath, 'utf8')).toBe( '# Current run\n\nFresh content.', diff --git a/src/lib/wizard-tools/handoff.ts b/src/lib/wizard-tools/handoff.ts index 4dedf0428..67411da68 100644 --- a/src/lib/wizard-tools/handoff.ts +++ b/src/lib/wizard-tools/handoff.ts @@ -1,9 +1,10 @@ /** * publish_handoff — the agent publishes the run's handoff doc (the report * markdown) in one explicit call, replacing the report file + watcher path. + * It leaves the tool as a `handoff` progress event; the host projects it. */ -import { getUI } from '@ui'; +import type { ProgressEmitter } from '@lib/agent/progress'; import { analytics } from '@utils/analytics'; import { logToFile } from '@utils/debug'; import { runtimeEnv } from '@env'; @@ -134,7 +135,10 @@ function writeHandoffFileAtomically( } } -export function publishHandoff(content: string): PublishHandoffResult { +export function publishHandoff( + content: string, + emit: ProgressEmitter | undefined, +): PublishHandoffResult { if (content.trim() === '') { analytics.wizardCapture('handoff published', { handoff_ok: false, @@ -148,7 +152,7 @@ export function publishHandoff(content: string): PublishHandoffResult { } const truncated = content.length > MAX_HANDOFF_TEXT_CHARS; const text = truncated ? content.slice(0, MAX_HANDOFF_TEXT_CHARS) : content; - getUI().setHandoffText(text); + emit?.({ kind: 'handoff', text }); const handoffOutputPath = runtimeEnv('POSTHOG_HANDOFF_OUTPUT_PATH'); let handoffOutputWritten: boolean | undefined; diff --git a/src/lib/wizard-tools/mcp.ts b/src/lib/wizard-tools/mcp.ts index c79510a9b..1b48eee2a 100644 --- a/src/lib/wizard-tools/mcp.ts +++ b/src/lib/wizard-tools/mcp.ts @@ -28,6 +28,7 @@ import { publishHandoff, } from './handoff'; import { createSecretVault, type SecretVault } from '../secret-vault'; +import type { ProgressEmitter } from '@lib/agent/progress'; import { buildOrchestratorTools, type OrchestratorToolsContext, @@ -147,6 +148,9 @@ export interface WizardToolsOptions { /** Scan-triage classifier for install_skill's scan, resolved by the caller. */ triageProvider: LLMProvider; + + /** Where `publish_handoff` reports. Absent → the handoff is written but reported nowhere. */ + emit?: ProgressEmitter; } /** Default per-run cap on wizard_ask calls when no override is provided. */ @@ -168,6 +172,7 @@ export async function createWizardToolsServer(options: WizardToolsOptions) { secretVault = createSecretVault(), orchestrator, triageProvider, + emit, } = options; const sdk = await getSDKModule(); const { tool, createSdkMcpServer } = sdk; @@ -755,7 +760,7 @@ export async function createWizardToolsServer(options: WizardToolsOptions) { content: z.string().describe(PUBLISH_HANDOFF_CONTENT_DESCRIPTION), }, (args: { content: string }) => { - const result = publishHandoff(args.content); + const result = publishHandoff(args.content, emit); logToFile(`publish_handoff: ${result.message}`); return { content: [{ type: 'text' as const, text: result.message }], diff --git a/src/ui/__tests__/agent-progress.test.ts b/src/ui/__tests__/agent-progress.test.ts index b6bf8c49e..afc4cb788 100644 --- a/src/ui/__tests__/agent-progress.test.ts +++ b/src/ui/__tests__/agent-progress.test.ts @@ -20,6 +20,7 @@ it('projects every progress event onto the matching UI call, in order', () => { 'addTokenUsage', 'setFinalTokenCostUsd', 'showAuthError', + 'setHandoffText', 'setOutroData', ] as const; for (const method of methods) { @@ -72,6 +73,7 @@ it('projects every progress event onto the matching UI call, in order', () => { { kind: 'usage', delta }, { kind: 'finalCost', usd: 1.25 }, { kind: 'authError', detail }, + { kind: 'handoff', text: '# Report' }, { kind: 'completion', outro }, { kind: 'lifecycle', phase: 'completed', message: 'Finished' }, ]; @@ -97,6 +99,7 @@ it('projects every progress event onto the matching UI call, in order', () => { ['addTokenUsage', delta], ['setFinalTokenCostUsd', 1.25], ['showAuthError', detail], + ['setHandoffText', '# Report'], ['setOutroData', outro], ['outro', 'Finished'], ]); diff --git a/src/ui/agent-progress.ts b/src/ui/agent-progress.ts index d3e3450cc..c5c406f65 100644 --- a/src/ui/agent-progress.ts +++ b/src/ui/agent-progress.ts @@ -47,6 +47,9 @@ export function createUiReducer(ui: WizardUI): (event: AgentProgress) => void { case 'authError': ui.showAuthError(event.detail); break; + case 'handoff': + ui.setHandoffText(event.text); + break; case 'completion': ui.setOutroData(event.outro); break; From 5cffae63a76335b62bdcec6cd169cde7e5141a28 Mon Sep 17 00:00:00 2001 From: "Vincent (Wen Yu) Ge" Date: Mon, 21 Sep 2026 21:34:21 -0400 Subject: [PATCH 11/13] build(lint): fence the agent's imports ESLint no-restricted-imports on today's agent paths: no @ui, session, detection, registry, runners, commands, steps, frameworks, setup-utils or oauth imports of any kind, no wizardAbort, and @lib/programs as types only until B1 moves PROGRAM_BINDINGS. No file allowlist; the rule runs in the editor and in `pnpm lint`. A2b collapses the path list to src/agent/**. Generated-By: PostHog Desktop Task-Id: d14e92bb-6ee1-49b5-8502-39cb80079589 --- .eslintrc.cjs | 68 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 184dc942a..e3fa6cb1d 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -30,6 +30,74 @@ module.exports = { 'prettier', ], overrides: [ + { + // The agent surface. It takes resolved data in, reports through + // progress events and asks through an injected answerer, so nothing + // here may reach a UI, the session, detection, the CLI or a program at + // runtime. Program types stay importable until B1 moves + // PROGRAM_BINDINGS to programs. Today's paths; A2b collapses them to + // src/agent/**. + files: [ + 'src/lib/agent/**/*.ts', + 'src/lib/middleware/**/*.ts', + 'src/lib/wizard-tools/**/*.ts', + 'src/lib/gateway-session.ts', + 'src/lib/safe-tools.ts', + 'src/lib/wizard-ask-bridge.ts', + 'src/lib/yara-hooks.ts', + 'src/lib/yara-policy.ts', + ], + excludedFiles: ['**/__tests__/**'], + rules: { + '@typescript-eslint/no-restricted-imports': [ + 'error', + { + paths: [ + { + name: '@utils/wizard-abort', + importNames: ['wizardAbort'], + message: + 'The agent never exits the process: return a failure in RunResult.', + }, + ], + patterns: [ + { + group: [ + '@ui', + '@ui/**', + '**/ui/**', + '@lib/wizard-session', + '**/wizard-session', + '@lib/detection/**', + '**/detection/**', + '@lib/registry', + '**/lib/registry', + '@lib/runners/**', + '**/runners/**', + '**/commands/**', + '@steps', + '@steps/**', + '@frameworks/**', + '**/frameworks/**', + '@utils/setup-utils', + '**/setup-utils', + '@utils/oauth', + '**/utils/oauth', + ], + message: + 'The agent reports through progress events and asks through AgentInteraction; it takes everything else through RunConfig and RunInput.', + }, + { + group: ['@lib/programs/**', '**/programs/**'], + allowTypeImports: true, + message: + 'The agent takes program data through RunConfig. Types only, until B1 moves PROGRAM_BINDINGS to programs.', + }, + ], + }, + ], + }, + }, { files: [ '*.test.js', From dcf418af73812da76c6deddbd7813969421e0bfa Mon Sep 17 00:00:00 2001 From: "Vincent (Wen Yu) Ge" Date: Wed, 23 Sep 2026 13:11:25 -0400 Subject: [PATCH 12/13] fix(lint): fence directory imports out of the agent too The agent fence matched files inside ui, detection, runners and steps but not the directories themselves, so `../../ui` or `@lib/detection` passed lint. Relative steps imports were not covered at all. A probe with eight such imports gave 1 error before and 8 after. The fence comment now says that only direct static imports are checked; shared helpers still reach the UI transitively. Generated-By: PostHog Desktop Task-Id: d14e92bb-6ee1-49b5-8502-39cb80079589 --- .eslintrc.cjs | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/.eslintrc.cjs b/.eslintrc.cjs index e3fa6cb1d..a633af786 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -33,10 +33,10 @@ module.exports = { { // The agent surface. It takes resolved data in, reports through // progress events and asks through an injected answerer, so nothing - // here may reach a UI, the session, detection, the CLI or a program at - // runtime. Program types stay importable until B1 moves - // PROGRAM_BINDINGS to programs. Today's paths; A2b collapses them to - // src/agent/**. + // here may import a UI, the session, detection, the CLI or a program. + // Only direct static imports are checked. Program types stay importable + // until B1 moves PROGRAM_BINDINGS to programs. Today's paths; A2b + // collapses them to src/agent/**. files: [ 'src/lib/agent/**/*.ts', 'src/lib/middleware/**/*.ts', @@ -65,18 +65,25 @@ module.exports = { group: [ '@ui', '@ui/**', + '**/ui', '**/ui/**', '@lib/wizard-session', '**/wizard-session', + '@lib/detection', '@lib/detection/**', + '**/detection', '**/detection/**', '@lib/registry', '**/lib/registry', + '@lib/runners', '@lib/runners/**', + '**/runners', '**/runners/**', '**/commands/**', '@steps', '@steps/**', + '**/steps', + '**/steps/**', '@frameworks/**', '**/frameworks/**', '@utils/setup-utils', From 3c7dfb5afe9ea68587dbd6e1b5a303b6549d1fcc Mon Sep 17 00:00:00 2001 From: "Vincent (Wen Yu) Ge" Date: Wed, 23 Sep 2026 13:11:36 -0400 Subject: [PATCH 13/13] test(agent): cover the handoff, benchmark and scan-summary wiring Each of these reaches the host through a callback the runner or a tool facade passes on, and the existing tests supplied their own callback. The new tests go through the real wiring: both registered publish_handoff tools (Pi and MCP), a linear run with --benchmark, and a scan summary on completion, agent abort and crash. Dropping any of those callbacks now fails a test; each was checked by removing it. Generated-By: PostHog Desktop Task-Id: d14e92bb-6ee1-49b5-8502-39cb80079589 --- .../__tests__/run-agent-standalone.test.ts | 87 +++++++++++++++++ .../__tests__/handoff-tools.test.ts | 96 +++++++++++++++++++ 2 files changed, 183 insertions(+) create mode 100644 src/lib/wizard-tools/__tests__/handoff-tools.test.ts diff --git a/src/lib/agent/__tests__/run-agent-standalone.test.ts b/src/lib/agent/__tests__/run-agent-standalone.test.ts index 2f03ff3fe..13756a9ec 100644 --- a/src/lib/agent/__tests__/run-agent-standalone.test.ts +++ b/src/lib/agent/__tests__/run-agent-standalone.test.ts @@ -131,6 +131,15 @@ vi.mock('@lib/agent/runner/switchboard/harness', () => { }, }); await askIfRequested(inputs); + // A real harness feeds the benchmark middleware every SDK message. + inputs.middleware?.onMessage({ + type: 'assistant', + message: { role: 'assistant', content: [{ type: 'text', text: 'hi' }] }, + }); + inputs.middleware?.finalize( + { type: 'result', modelUsage: {}, num_turns: 1 }, + 10, + ); if (harnessState.throws) throw harnessState.throws; spinner.stop('Done'); return harnessState.result as never; @@ -685,4 +694,82 @@ describe('runAgent standalone', () => { // What was reported before the crash survives in the snapshot. expect(result.snapshot.statusMessages).toContain('Installing the SDK'); }); + + it('sends benchmark output to onProgress when benchmarking', async () => { + const benchmarkPath = path.join(tmp, 'benchmark.json'); + const configPath = path.join(tmp, '.benchmark-config.json'); + fs.writeFileSync( + configPath, + JSON.stringify({ output: { benchmarkPath, logEnabled: false } }), + ); + vi.stubEnv('POSTHOG_WIZARD_BENCHMARK_CONFIG', configPath); + vi.stubEnv('POSTHOG_WIZARD_BENCHMARK_FILE', benchmarkPath); + vi.stubEnv('POSTHOG_WIZARD_LOG_DIR', tmp); + const runInput = input(); + runInput.flags.benchmark = true; + const events: AgentProgress[] = []; + try { + const result = await runAgent(config(), runInput, { + onProgress: (e) => events.push(e), + }); + + expect(result.outcome).toBe('success'); + const logs = events.flatMap((e) => (e.kind === 'log' ? [e.message] : [])); + expect(logs).toContainEqual( + expect.stringContaining( + `Benchmark data will be written to: ${benchmarkPath}`, + ), + ); + expect(logs).toContainEqual( + expect.stringContaining(`Results written to ${benchmarkPath}`), + ); + expect(fs.existsSync(benchmarkPath)).toBe(true); + } finally { + vi.unstubAllEnvs(); + } + }); + + it.each<[string, () => void, string]>([ + ['completes', () => undefined, 'success'], + [ + 'aborts', + () => { + harnessState.result = { + error: AgentErrorType.ABORT, + message: 'No Stripe found', + }; + }, + 'aborted', + ], + [ + 'crashes', + () => { + harnessState.throws = new Error('SDK exploded'); + }, + 'crashed', + ], + ])( + 'sends the scan summary to onProgress when the run %s', + async (_ending, arrange, outcome) => { + const summary = + 'YARA scan report: /tmp/yara.json\n— YARA Scanner Summary —'; + vi.mocked(flushScanReport).mockReturnValueOnce(summary); + arrange(); + const runInput = input(); + runInput.flags.yaraReport = true; + const events: AgentProgress[] = []; + + const result = await runAgent(config(), runInput, { + onProgress: (e) => events.push(e), + }); + + expect(result.outcome).toBe(outcome); + expect(flushScanReport).toHaveBeenCalledWith({ yaraReport: true }); + expect(events).toContainEqual({ + kind: 'log', + level: 'info', + message: summary, + }); + }, + ); }); diff --git a/src/lib/wizard-tools/__tests__/handoff-tools.test.ts b/src/lib/wizard-tools/__tests__/handoff-tools.test.ts new file mode 100644 index 000000000..6f3b6f062 --- /dev/null +++ b/src/lib/wizard-tools/__tests__/handoff-tools.test.ts @@ -0,0 +1,96 @@ +// Both registered publish_handoff tools, Pi and MCP, must hand the report to the host. +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import type { LLMProvider } from '@posthog/warlock'; +import type { AgentProgress } from '@lib/agent/progress'; +import { createWizardPiTools } from '@lib/agent/runner/harness/pi/tools'; +import { createWizardToolsServer } from '../mcp'; +import { PUBLISH_HANDOFF_TOOL_NAME } from '../handoff'; + +vi.mock('@ui', () => ({ + getUI: () => { + throw new Error('agent code reached the UI'); + }, +})); +// The MCP server's tool list, without the SDK's transport around it. +vi.mock('@anthropic-ai/claude-agent-sdk', () => ({ + tool: ( + name: string, + description: string, + inputSchema: unknown, + handler: (args: unknown) => unknown, + ) => ({ name, description, inputSchema, handler }), + createSdkMcpServer: (options: unknown) => options, +})); +vi.mock('../tools', async (original) => ({ + ...(await original()), + fetchSkillMenu: vi.fn().mockResolvedValue(null), +})); + +const REPORT = '# Setup report\n\nAll done.'; + +describe('registered publish_handoff tools', () => { + let workingDirectory: string; + let events: AgentProgress[]; + let ambientOutputPath: string | undefined; + + beforeEach(() => { + workingDirectory = mkdtempSync(join(tmpdir(), 'wizard-handoff-tools-')); + events = []; + ambientOutputPath = process.env.POSTHOG_HANDOFF_OUTPUT_PATH; + delete process.env.POSTHOG_HANDOFF_OUTPUT_PATH; + }); + + afterEach(() => { + rmSync(workingDirectory, { recursive: true, force: true }); + if (ambientOutputPath === undefined) { + delete process.env.POSTHOG_HANDOFF_OUTPUT_PATH; + } else { + process.env.POSTHOG_HANDOFF_OUTPUT_PATH = ambientOutputPath; + } + }); + + const handoffs = () => + events.flatMap((event) => (event.kind === 'handoff' ? [event.text] : [])); + + it('the Pi tool reports the handoff to the host', async () => { + const tools = createWizardPiTools({ + workingDirectory, + skillsBaseUrl: 'http://localhost:0', + emit: (event) => events.push(event), + }); + const tool = tools.find((t) => t.name === PUBLISH_HANDOFF_TOOL_NAME); + if (!tool) throw new Error('publish_handoff not registered'); + + const result = (await ( + tool.execute as (id: string, args: unknown) => Promise + )('call-1', { content: REPORT })) as { content: [{ text: string }] }; + + expect(result.content[0].text).toContain('Handoff published'); + expect(handoffs()).toEqual([REPORT]); + }); + + it('the MCP tool reports the handoff to the host', async () => { + const server = (await createWizardToolsServer({ + workingDirectory, + detectPackageManager: vi.fn(), + skillsBaseUrl: 'http://localhost:0', + triageProvider: {} as LLMProvider, + emit: (event) => events.push(event), + })) as unknown as { + tools: { name: string; handler: (args: unknown) => unknown }[]; + }; + const tool = server.tools.find((t) => t.name === PUBLISH_HANDOFF_TOOL_NAME); + if (!tool) throw new Error('publish_handoff not registered'); + + const result = (await tool.handler({ content: REPORT })) as { + content: [{ text: string }]; + isError?: boolean; + }; + + expect(result.isError).toBeUndefined(); + expect(result.content[0].text).toContain('Handoff published'); + expect(handoffs()).toEqual([REPORT]); + }); +});