From d2041fb169fd2fe44f83feca09bbc078fc64f8d4 Mon Sep 17 00:00:00 2001 From: "posthog[bot]" <206114724+posthog[bot]@users.noreply.github.com> Date: Thu, 3 Sep 2026 11:32:38 +0000 Subject: [PATCH 1/7] fix(agent): tell users to clear a broken npx cache instead of "API Error" A half-written ~/.npm/_npx extraction makes Node fail the pi-coding-agent dynamic import with ERR_MODULE_NOT_FOUND. Both pi catch blocks classified that as AgentErrorType.API_ERROR, so the user saw an "API Error" heading and a request to email support, for a problem they can fix in one command. Add AgentErrorType.MODULE_MISSING (PHW_AGENT_MODULE_MISSING), detect the Node error in the run and task catch blocks, and render a message that names the exact cached download to delete and the command to rerun. Generated-By: PostHog Desktop Task-Id: 9fe44d3a-6a47-4d1d-8950-c84b7281c08b --- src/lib/agent/runner/harness/pi/index.ts | 4 ++ src/lib/agent/runner/harness/pi/task.ts | 4 ++ src/lib/agent/runner/sequence/linear.ts | 21 +++++++ src/lib/agent/signals.ts | 2 + .../errors/__tests__/module-missing.test.ts | 45 +++++++++++++++ src/lib/errors/agent-map.ts | 1 + src/lib/errors/catalog.ts | 6 ++ src/lib/errors/codes.ts | 1 + src/lib/errors/module-missing.ts | 57 +++++++++++++++++++ 9 files changed, 141 insertions(+) create mode 100644 src/lib/errors/__tests__/module-missing.test.ts create mode 100644 src/lib/errors/module-missing.ts diff --git a/src/lib/agent/runner/harness/pi/index.ts b/src/lib/agent/runner/harness/pi/index.ts index 772e39b36..9017b0acb 100644 --- a/src/lib/agent/runner/harness/pi/index.ts +++ b/src/lib/agent/runner/harness/pi/index.ts @@ -24,6 +24,7 @@ import { } from '@lib/constants'; import { analytics } from '@utils/analytics'; import { AgentErrorType } from '@lib/agent/agent-interface'; +import { isModuleNotFoundError } from '@lib/errors/module-missing'; import { AgentSignals, REMARK_INSTRUCTION } from '@lib/agent/signals'; import { AgentOutputSignals } from '@lib/agent/output-signals'; import { assembleCommandments } from '../../switchboard/commandments'; @@ -642,6 +643,9 @@ export const piBackend: AgentHarness = { if (lower.includes('rate limit') || lower.includes('429')) { return { error: AgentErrorType.RATE_LIMIT, message }; } + if (isModuleNotFoundError(err)) { + return { error: AgentErrorType.MODULE_MISSING, message }; + } return { error: AgentErrorType.API_ERROR, message }; } }, diff --git a/src/lib/agent/runner/harness/pi/task.ts b/src/lib/agent/runner/harness/pi/task.ts index 826c9fbf7..6b5f8cad9 100644 --- a/src/lib/agent/runner/harness/pi/task.ts +++ b/src/lib/agent/runner/harness/pi/task.ts @@ -31,6 +31,7 @@ import { renderToolInventory, } from '@lib/agent/agent-prompt-loader'; import { AgentErrorType } from '@lib/agent/agent-interface'; +import { isModuleNotFoundError } from '@lib/errors/module-missing'; import { REMARK_INSTRUCTION } from '@lib/agent/signals'; import { AgentOutputSignals } from '@lib/agent/output-signals'; import { TaskStatus } from '../../sequence/orchestrator/queue'; @@ -511,6 +512,9 @@ export async function runPiTask(inputs: TaskRunInputs): Promise { if (lower.includes('rate limit') || lower.includes('429')) { return { error: AgentErrorType.RATE_LIMIT, message }; } + if (isModuleNotFoundError(err)) { + return { error: AgentErrorType.MODULE_MISSING, message }; + } return { error: AgentErrorType.API_ERROR, message }; } } diff --git a/src/lib/agent/runner/sequence/linear.ts b/src/lib/agent/runner/sequence/linear.ts index 94ab86841..18acf6106 100644 --- a/src/lib/agent/runner/sequence/linear.ts +++ b/src/lib/agent/runner/sequence/linear.ts @@ -18,6 +18,7 @@ import { registerCleanup, } from '../../../../utils/wizard-abort'; import { ErrorCodes, AGENT_ERROR_CODE } from '@lib/errors'; +import { formatModuleMissingMessage } from '@lib/errors/module-missing'; import { analytics } from '../../../../utils/analytics'; import { formatScanReport, @@ -280,6 +281,26 @@ export async function runLinearProgram( }); } + if (agentResult.error === AgentErrorType.MODULE_MISSING) { + analytics.wizardCapture('agent module missing', { + integration: config.integrationLabel, + error_type: AgentErrorType.MODULE_MISSING, + error_message: agentResult.message, + }); + await wizardAbort({ + code: AGENT_ERROR_CODE[AgentErrorType.MODULE_MISSING], + message: formatModuleMissingMessage(agentResult.message ?? ''), + error: new WizardError( + `Dependency missing from the npx download: ${agentResult.message}`, + { + integration: config.integrationLabel, + error_type: AgentErrorType.MODULE_MISSING, + }, + AGENT_ERROR_CODE[AgentErrorType.MODULE_MISSING], + ), + }); + } + if ( agentResult.error === AgentErrorType.RATE_LIMIT || agentResult.error === AgentErrorType.API_ERROR diff --git a/src/lib/agent/signals.ts b/src/lib/agent/signals.ts index f66f3ee36..f2c41b022 100644 --- a/src/lib/agent/signals.ts +++ b/src/lib/agent/signals.ts @@ -66,6 +66,8 @@ export enum AgentErrorType { RATE_LIMIT = 'WIZARD_RATE_LIMIT', /** Generic API error */ API_ERROR = 'WIZARD_API_ERROR', + /** A wizard dependency is absent — a half-written npx download */ + MODULE_MISSING = 'WIZARD_MODULE_MISSING', /** YARA scanner detected a security violation */ YARA_VIOLATION = 'WIZARD_YARA_VIOLATION', /** Agent intentionally aborted the program (emitted [ABORT] ) */ diff --git a/src/lib/errors/__tests__/module-missing.test.ts b/src/lib/errors/__tests__/module-missing.test.ts new file mode 100644 index 000000000..f60cb1fc2 --- /dev/null +++ b/src/lib/errors/__tests__/module-missing.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest'; +import { + formatModuleMissingMessage, + isModuleNotFoundError, +} from '../module-missing'; + +describe('isModuleNotFoundError', () => { + it('recognises the Node error code', () => { + const err = Object.assign(new Error('boom'), { + code: 'ERR_MODULE_NOT_FOUND', + }); + expect(isModuleNotFoundError(err)).toBe(true); + }); + + // A dynamic import rethrown across a boundary keeps the text, not the code. + it('recognises the message alone', () => { + expect( + isModuleNotFoundError( + new Error( + "Cannot find package '/Users/a/.npm/_npx/9f2/node_modules/chalk/index.js'", + ), + ), + ).toBe(true); + }); + + it('leaves an ordinary API failure alone', () => { + expect(isModuleNotFoundError(new Error('502 Bad Gateway'))).toBe(false); + }); +}); + +describe('formatModuleMissingMessage', () => { + it('names the exact download to delete', () => { + const message = formatModuleMissingMessage( + "Cannot find package '/Users/a/.npm/_npx/9f2/node_modules/chalk/index.js'", + ); + expect(message).toContain('rm -rf "/Users/a/.npm/_npx/9f2"'); + expect(message).toContain('npx @posthog/wizard@latest'); + }); + + it('falls back to the whole npx cache', () => { + expect(formatModuleMissingMessage('Cannot find module x')).toContain( + '_npx', + ); + }); +}); diff --git a/src/lib/errors/agent-map.ts b/src/lib/errors/agent-map.ts index d9a46a667..47a6cbece 100644 --- a/src/lib/errors/agent-map.ts +++ b/src/lib/errors/agent-map.ts @@ -6,6 +6,7 @@ export const AGENT_ERROR_CODE: Record = { [AgentErrorType.RESOURCE_MISSING]: ErrorCodes.AgentResourceMissing, [AgentErrorType.RATE_LIMIT]: ErrorCodes.AgentRateLimit, [AgentErrorType.API_ERROR]: ErrorCodes.AgentApiError, + [AgentErrorType.MODULE_MISSING]: ErrorCodes.AgentModuleMissing, [AgentErrorType.YARA_VIOLATION]: ErrorCodes.AgentYaraViolation, [AgentErrorType.ABORT]: ErrorCodes.AgentAbort, [AgentErrorType.NO_PROGRESS]: ErrorCodes.AgentNoProgress, diff --git a/src/lib/errors/catalog.ts b/src/lib/errors/catalog.ts index 7d2c026d5..ebd2d1064 100644 --- a/src/lib/errors/catalog.ts +++ b/src/lib/errors/catalog.ts @@ -196,6 +196,12 @@ export const ERROR_CATALOG: Record = { retry: 'yes', description: 'The agent hit an API error other than a rate limit.', }, + [ErrorCodes.AgentModuleMissing]: { + group: 'agent', + retry: 'no', + description: + 'A wizard dependency was absent from the npx download. The user must delete the npx cache and run again.', + }, [ErrorCodes.AgentYaraViolation]: { group: 'agent', retry: 'no', diff --git a/src/lib/errors/codes.ts b/src/lib/errors/codes.ts index 6342b79b0..9286905f0 100644 --- a/src/lib/errors/codes.ts +++ b/src/lib/errors/codes.ts @@ -37,6 +37,7 @@ export const ErrorCodes = { AgentResourceMissing: 'PHW_AGENT_RESOURCE_MISSING', AgentRateLimit: 'PHW_AGENT_RATE_LIMIT', AgentApiError: 'PHW_AGENT_API_ERROR', + AgentModuleMissing: 'PHW_AGENT_MODULE_MISSING', AgentYaraViolation: 'PHW_AGENT_YARA_VIOLATION', AgentNoProgress: 'PHW_AGENT_NO_PROGRESS', AgentIncompleteTasks: 'PHW_AGENT_INCOMPLETE_TASKS', diff --git a/src/lib/errors/module-missing.ts b/src/lib/errors/module-missing.ts new file mode 100644 index 000000000..29c37dda2 --- /dev/null +++ b/src/lib/errors/module-missing.ts @@ -0,0 +1,57 @@ +import * as os from 'os'; +import * as path from 'path'; + +/** + * A half-written `~/.npm/_npx` extraction leaves a dependency directory without + * a readable package.json, and Node then fails the dynamic import with + * ERR_MODULE_NOT_FOUND. The install is corrupt, not the API — the user fixes it + * by deleting the cached download and running the wizard again. + */ +export function isModuleNotFoundError(err: unknown): boolean { + const code = (err as { code?: unknown } | null)?.code; + if (code === 'ERR_MODULE_NOT_FOUND' || code === 'MODULE_NOT_FOUND') { + return true; + } + const message = err instanceof Error ? err.message : String(err ?? ''); + return /ERR_MODULE_NOT_FOUND|Cannot find (?:package|module)/i.test(message); +} + +/** + * The exact download to delete, taken from the path Node names in the failure + * (`.../_npx//node_modules/...`). Falls back to the whole npx cache when + * the message carries no such path. + */ +function npxCacheDir(message: string): string { + const match = /([^\s'"]*[/\\]_npx[/\\][^/\\'"]+)/.exec(message); + if (match) return match[1]; + return process.platform === 'win32' + ? path.join( + process.env.LOCALAPPDATA ?? path.join(os.homedir(), 'AppData', 'Local'), + 'npm-cache', + '_npx', + ) + : path.join(os.homedir(), '.npm', '_npx'); +} + +export function formatModuleMissingMessage(message: string): string { + const dir = npxCacheDir(message); + const remove = + process.platform === 'win32' + ? `Remove-Item -Recurse -Force "${dir}"` + : `rm -rf "${dir}"`; + return [ + 'Broken npx download', + '', + 'The wizard could not load one of its own dependencies. The npx cache holds', + 'an incomplete copy of it. Nothing is wrong with your project.', + '', + 'Delete the cached download, then run the wizard again:', + '', + ` ${remove}`, + ' npx @posthog/wizard@latest', + '', + `Details: ${message}`, + '', + 'Still stuck? Email wizard@posthog.com and we will help.', + ].join('\n'); +} From 1f1232f11d59d50fa661aa003d02b84005ab874d Mon Sep 17 00:00:00 2001 From: "posthog[bot]" <206114724+posthog[bot]@users.noreply.github.com> Date: Thu, 3 Sep 2026 11:58:58 +0000 Subject: [PATCH 2/7] docs(errors): document PHW_AGENT_MODULE_MISSING in the error catalog The catalog doc's "Extending the catalog" procedure requires a table row for every new code. PHW_AGENT_MODULE_MISSING had the codes.ts constant and the ERROR_CATALOG entry but no documented row, so an integrator reading the markdown mirror would fall back to a default retry policy on a failure that never heals between runs. Generated-By: PostHog Desktop Task-Id: 4c97b145-3d33-4a0c-b890-f58cb5ef8189 --- docs/error-catalog.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/error-catalog.md b/docs/error-catalog.md index 7e90f99d5..fb8157291 100644 --- a/docs/error-catalog.md +++ b/docs/error-catalog.md @@ -99,6 +99,7 @@ screen, debug log) keep the full detail. | `PHW_AGENT_RESOURCE_MISSING` | agent | `[ERROR-RESOURCE-MISSING]` — setup resource unavailable | yes | | `PHW_AGENT_RATE_LIMIT` | agent | LLM gateway rate limit | yes | | `PHW_AGENT_API_ERROR` | agent | other API failure during the agent run | yes | +| `PHW_AGENT_MODULE_MISSING` | agent | a wizard dependency was missing from the npx download; the user must delete the cached download and rerun | no | | `PHW_AGENT_YARA_VIOLATION` | agent | security scanner terminated the run | no | | `PHW_AGENT_NO_PROGRESS` | agent | agent ended with zero tool calls | case-by-case | | `PHW_AGENT_INCOMPLETE_TASKS` | agent | agent stopped with planned tasks open | case-by-case | From 2b5867a5d05c4dd4912722163147ebc63274cc26 Mon Sep 17 00:00:00 2001 From: "posthog[bot]" <206114724+posthog[bot]@users.noreply.github.com> Date: Thu, 3 Sep 2026 12:01:41 +0000 Subject: [PATCH 3/7] fix(agent): classify module-not-found before the rate-limit substring test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rate-limit branch matches "429" anywhere in the lowercased message. An npx cache directory is a 16-character hex hash, and Node's ERR_MODULE_NOT_FOUND message embeds that path twice, so a hash containing "429" made a broken npx download report as a rate limit — and from there as the same "API Error" text this change set out to replace. Module resolution is decided by the error object itself, so it now goes first in both pi catch blocks. A genuine rate-limit message never satisfies isModuleNotFoundError, so no other input changes classification. The loose "429" substring test is pre-existing and left as is; tightening it to a status token is a separate change to rate-limit handling. Generated-By: PostHog Desktop Task-Id: 4c97b145-3d33-4a0c-b890-f58cb5ef8189 --- src/lib/agent/runner/harness/pi/index.ts | 6 +++--- src/lib/agent/runner/harness/pi/task.ts | 6 +++--- src/lib/errors/__tests__/module-missing.test.ts | 12 ++++++++++++ 3 files changed, 18 insertions(+), 6 deletions(-) diff --git a/src/lib/agent/runner/harness/pi/index.ts b/src/lib/agent/runner/harness/pi/index.ts index 9017b0acb..6172086b8 100644 --- a/src/lib/agent/runner/harness/pi/index.ts +++ b/src/lib/agent/runner/harness/pi/index.ts @@ -640,12 +640,12 @@ export const piBackend: AgentHarness = { captureAborted(); const lower = message.toLowerCase(); - if (lower.includes('rate limit') || lower.includes('429')) { - return { error: AgentErrorType.RATE_LIMIT, message }; - } if (isModuleNotFoundError(err)) { return { error: AgentErrorType.MODULE_MISSING, message }; } + if (lower.includes('rate limit') || lower.includes('429')) { + return { error: AgentErrorType.RATE_LIMIT, message }; + } return { error: AgentErrorType.API_ERROR, message }; } }, diff --git a/src/lib/agent/runner/harness/pi/task.ts b/src/lib/agent/runner/harness/pi/task.ts index 6b5f8cad9..26c0a97e8 100644 --- a/src/lib/agent/runner/harness/pi/task.ts +++ b/src/lib/agent/runner/harness/pi/task.ts @@ -509,12 +509,12 @@ export async function runPiTask(inputs: TaskRunInputs): Promise { } captureAborted(); const lower = message.toLowerCase(); - if (lower.includes('rate limit') || lower.includes('429')) { - return { error: AgentErrorType.RATE_LIMIT, message }; - } if (isModuleNotFoundError(err)) { return { error: AgentErrorType.MODULE_MISSING, message }; } + if (lower.includes('rate limit') || lower.includes('429')) { + return { error: AgentErrorType.RATE_LIMIT, message }; + } return { error: AgentErrorType.API_ERROR, message }; } } diff --git a/src/lib/errors/__tests__/module-missing.test.ts b/src/lib/errors/__tests__/module-missing.test.ts index f60cb1fc2..6b2135244 100644 --- a/src/lib/errors/__tests__/module-missing.test.ts +++ b/src/lib/errors/__tests__/module-missing.test.ts @@ -26,6 +26,18 @@ describe('isModuleNotFoundError', () => { it('leaves an ordinary API failure alone', () => { expect(isModuleNotFoundError(new Error('502 Bad Gateway'))).toBe(false); }); + + // An npx cache hash is 16 hex characters, so it can contain "429" — which the + // harness's rate-limit substring test would otherwise claim first. + it('recognises a cache hash that looks like a rate limit', () => { + expect( + isModuleNotFoundError( + new Error( + "Cannot find package '/Users/a/.npm/_npx/429abc0d15e7f318/node_modules/chalk/index.js'", + ), + ), + ).toBe(true); + }); }); describe('formatModuleMissingMessage', () => { From 80593148e032a032054bc07a2977bfb9ddde9901 Mon Sep 17 00:00:00 2001 From: "posthog[bot]" <206114724+posthog[bot]@users.noreply.github.com> Date: Thu, 3 Sep 2026 12:03:23 +0000 Subject: [PATCH 4/7] fix(errors): stop naming the default flow in the npx recovery message Every non-orchestrator program renders this message through linear.ts, not just the default integration flow. The literal `npx @posthog/wizard@latest` line sat directly under a "run the wizard again" instruction, so a user who ran a read-only command such as `wizard audit events` would copy-paste their way into the base integration flow, which changes their project. The message now asks the user to run the same wizard command again, which is correct for every command and for an explicit --install-dir. Rebuilding the real invocation from argv was rejected: it would have to filter secret option values such as --api-key for no gain over prose. Generated-By: PostHog Desktop Task-Id: 4c97b145-3d33-4a0c-b890-f58cb5ef8189 --- src/lib/errors/__tests__/module-missing.test.ts | 8 +++++++- src/lib/errors/module-missing.ts | 7 +++++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/src/lib/errors/__tests__/module-missing.test.ts b/src/lib/errors/__tests__/module-missing.test.ts index 6b2135244..501a9856e 100644 --- a/src/lib/errors/__tests__/module-missing.test.ts +++ b/src/lib/errors/__tests__/module-missing.test.ts @@ -46,7 +46,13 @@ describe('formatModuleMissingMessage', () => { "Cannot find package '/Users/a/.npm/_npx/9f2/node_modules/chalk/index.js'", ); expect(message).toContain('rm -rf "/Users/a/.npm/_npx/9f2"'); - expect(message).toContain('npx @posthog/wizard@latest'); + }); + + // Every wizard command reaches this message, so it must not name one. + it('asks for the same command again rather than the default flow', () => { + const message = formatModuleMissingMessage('Cannot find module x'); + expect(message).toContain('run the same wizard command again'); + expect(message).not.toContain('npx @posthog/wizard@latest'); }); it('falls back to the whole npx cache', () => { diff --git a/src/lib/errors/module-missing.ts b/src/lib/errors/module-missing.ts index 29c37dda2..f8df684f7 100644 --- a/src/lib/errors/module-missing.ts +++ b/src/lib/errors/module-missing.ts @@ -45,10 +45,13 @@ export function formatModuleMissingMessage(message: string): string { 'The wizard could not load one of its own dependencies. The npx cache holds', 'an incomplete copy of it. Nothing is wrong with your project.', '', - 'Delete the cached download, then run the wizard again:', + 'Delete the cached download:', '', ` ${remove}`, - ' npx @posthog/wizard@latest', + '', + // Not a literal rerun command: every wizard command reaches this message, + // so naming the default flow would send an `audit` user into an install. + 'Then run the same wizard command again. npx fetches a fresh copy.', '', `Details: ${message}`, '', From 5174d886d5f83e8b5074455bde64dfb3111ea771 Mon Sep 17 00:00:00 2001 From: "posthog[bot]" <206114724+posthog[bot]@users.noreply.github.com> Date: Thu, 3 Sep 2026 12:08:33 +0000 Subject: [PATCH 5/7] fix(agent): route orchestrator module-missing results to the recovery screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit runPiTask classified a broken npx download, but nothing read the result: the executor's RunTask returns void and the drain call discarded it, while the seed path only logged. Orchestrator runs — the default integration flow and metrics — therefore still ended on the generic "report this to us" text, which is the population this change was written for. Both paths now record the failure, and a run that ended badly aborts with PHW_AGENT_MODULE_MISSING and the cache repair command before the queue verdict is read. The abort is gated on the run having actually failed, so a run that completed in spite of one bad task keeps its result. Reusing one AgentResult router across the linear and orchestrator sequences was rejected here: the two abort on different verdicts, and the shared abstraction is a larger design change than this fix needs. Generated-By: PostHog Desktop Task-Id: 4c97b145-3d33-4a0c-b890-f58cb5ef8189 --- .../orchestrator/orchestrator-runner.ts | 43 ++++++++++++++++++- 1 file changed, 42 insertions(+), 1 deletion(-) diff --git a/src/lib/agent/runner/sequence/orchestrator/orchestrator-runner.ts b/src/lib/agent/runner/sequence/orchestrator/orchestrator-runner.ts index d6a75a592..4b005f276 100644 --- a/src/lib/agent/runner/sequence/orchestrator/orchestrator-runner.ts +++ b/src/lib/agent/runner/sequence/orchestrator/orchestrator-runner.ts @@ -43,6 +43,8 @@ import { logToFile } from '@utils/debug'; import { ringTerminalBell } from '@utils/terminal-bell'; import { wizardAbort, WizardError } from '@utils/wizard-abort'; import { ErrorCodes } from '@lib/errors'; +import { formatModuleMissingMessage } from '@lib/errors/module-missing'; +import { AgentErrorType } from '../../../agent-interface'; import type { ProgramConfig } from '@lib/programs/program-step'; import type { BootstrapResult, ProgramRun } from '../../shared/types'; import { @@ -760,6 +762,10 @@ export async function runOrchestrator( // Prompt-frontmatter model wins over the switchboard pick (§3.6 of the // switchboard plan) — the switchboard's model is the fallback when the // prompt is silent. + // A half-written npx download breaks the same import in every agent, so the + // first path that reports it — seed or task — stands for the whole run. The + // executor discards task results, so the value is collected here. + let moduleMissing: string | undefined; const seedPick = resolveHarness(switchboardCtx, 'seed'); const seedHarness = requireTaskHarness(seedPick); const seedModel = promptModelFor(seedPrompt, seedPick.harness); @@ -785,6 +791,9 @@ export async function runOrchestrator( seedResult.message ?? '' }`, ); + if (seedResult.error === AgentErrorType.MODULE_MISSING) { + moduleMissing ??= seedResult.message ?? ''; + } } analytics.wizardCapture('orchestrator seeded', { task_count: store.list().length, @@ -998,7 +1007,7 @@ export async function runOrchestrator( const taskPick = resolveHarness(switchboardCtx, task.type); const taskHarness = requireTaskHarness(taskPick); const taskModel = taskModelSpec(registry, task, taskPick.harness); - await taskHarness.runTask({ + const taskResult = await taskHarness.runTask({ session, programConfig, boot, @@ -1020,6 +1029,9 @@ export async function runOrchestrator( harness: taskPick.harness, }, }); + if (taskResult.error === AgentErrorType.MODULE_MISSING) { + moduleMissing ??= taskResult.message ?? ''; + } } finally { // Durable skills a task installed are irrelevant to later tasks — and // the sdk harness auto-loads .claude/skills into every agent — so sweep @@ -1117,6 +1129,35 @@ export async function runOrchestrator( // A failed optional task is exempt: reported per-task, never run-failing. const verdict = drainVerdict(store.list()); const blocked = verdict.blocked; + + // A run that failed because a wizard dependency never loaded gets the cache + // repair command instead of the generic "report this to us" text — no retry + // heals a corrupt extraction. A run that finished anyway keeps its result. + if ( + moduleMissing !== undefined && + (verdict.requiredFailedTypes.length > 0 || + blocked > 0 || + summary.total === 0) + ) { + analytics.wizardCapture('agent module missing', { + integration: programConfig.id, + error_type: AgentErrorType.MODULE_MISSING, + error_message: moduleMissing, + }); + await wizardAbort({ + code: ErrorCodes.AgentModuleMissing, + message: formatModuleMissingMessage(moduleMissing), + error: new WizardError( + `Dependency missing from the npx download: ${moduleMissing}`, + { + integration: programConfig.id, + error_type: AgentErrorType.MODULE_MISSING, + }, + ErrorCodes.AgentModuleMissing, + ), + }); + } + if (verdict.requiredFailedTypes.length > 0 || blocked > 0) { const failedTypes = verdict.requiredFailedTypes.join(', '); const whatFailed = failedTypes From 64d81be223449a13d99623cd6f47352ae7116fa4 Mon Sep 17 00:00:00 2001 From: "posthog[bot]" <206114724+posthog[bot]@users.noreply.github.com> Date: Thu, 3 Sep 2026 12:18:41 +0000 Subject: [PATCH 6/7] fix(errors): keep an npx cache path with a space in it whole MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prefix before `_npx` excluded whitespace, so a home directory with a space in it started the match after the last space: `/Users/First Last/.npm/_npx/9f2` was captured as `Last/.npm/_npx/9f2`. The printed repair then targeted a relative path — on POSIX `rm -rf` matches nothing and exits 0, so the user believes the cache is clear, reruns, and hits the identical failure. Windows, where `C:\Users\John Smith` is ordinary, fails loudly instead. The prefix is now fenced by the quotes Node puts around a specifier rather than by whitespace, and has to start at a real root (`/`, a drive, or a UNC share). A fragment that cannot be rooted no longer matches at all, so it reaches the whole-cache fallback instead of being printed as if it were verified — half a path is worse than none here. The trailing segment stops at whitespace too, so prose after the hash is no longer swallowed. Covered by three tests: a POSIX path with a space, a Windows path with a space, and an unrooted fragment falling back. Generated-By: PostHog Desktop Task-Id: 398deef2-51b6-41a3-9b19-10e368612efe --- .../errors/__tests__/module-missing.test.ts | 31 +++++++++++++++++++ src/lib/errors/module-missing.ts | 14 +++++++-- 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/src/lib/errors/__tests__/module-missing.test.ts b/src/lib/errors/__tests__/module-missing.test.ts index 501a9856e..7cc893f4d 100644 --- a/src/lib/errors/__tests__/module-missing.test.ts +++ b/src/lib/errors/__tests__/module-missing.test.ts @@ -48,6 +48,37 @@ describe('formatModuleMissingMessage', () => { expect(message).toContain('rm -rf "/Users/a/.npm/_npx/9f2"'); }); + // A Windows profile is routinely `C:\Users\John Smith`, and a POSIX home can + // hold a space too. Capturing from the last space left a relative path, and + // `rm -rf` on a relative path matches nothing and exits 0 — the user believes + // the cache is clear, reruns, and hits the identical failure. + it('keeps a POSIX cache path that contains a space whole', () => { + expect( + formatModuleMissingMessage( + "Cannot find package 'chalk' imported from /Users/First Last/.npm/_npx/9f2abc1234567890/node_modules/pi/dist/index.js", + ), + ).toContain('rm -rf "/Users/First Last/.npm/_npx/9f2abc1234567890"'); + }); + + it('keeps a Windows cache path that contains a space whole', () => { + expect( + formatModuleMissingMessage( + "Cannot find package 'chalk' imported from C:\\Users\\John Smith\\AppData\\Local\\npm-cache\\_npx\\abc1234567890def\\node_modules\\x.js", + ), + ).toContain( + 'rm -rf "C:\\Users\\John Smith\\AppData\\Local\\npm-cache\\_npx\\abc1234567890def"', + ); + }); + + // Half a path is worse than none, so a fragment with no root is not printed. + it('falls back rather than naming a path it cannot root', () => { + const message = formatModuleMissingMessage( + "Cannot find module 'foo/_npx/9f2/node_modules/x'", + ); + expect(message).not.toContain('rm -rf "foo/_npx/9f2"'); + expect(message).toMatch(/rm -rf "\/[^"]*_npx"/); + }); + // Every wizard command reaches this message, so it must not name one. it('asks for the same command again rather than the default flow', () => { const message = formatModuleMissingMessage('Cannot find module x'); diff --git a/src/lib/errors/module-missing.ts b/src/lib/errors/module-missing.ts index f8df684f7..c59b98d31 100644 --- a/src/lib/errors/module-missing.ts +++ b/src/lib/errors/module-missing.ts @@ -19,10 +19,20 @@ export function isModuleNotFoundError(err: unknown): boolean { /** * The exact download to delete, taken from the path Node names in the failure * (`.../_npx//node_modules/...`). Falls back to the whole npx cache when - * the message carries no such path. + * the message carries no absolute path of that shape. */ function npxCacheDir(message: string): string { - const match = /([^\s'"]*[/\\]_npx[/\\][^/\\'"]+)/.exec(message); + // The part before `_npx` has to tolerate spaces — a Windows profile is + // routinely `C:\Users\John Smith` — so it is fenced by the quotes Node puts + // around a specifier rather than by whitespace, and it has to start at a real + // root (`/`, a drive, or a UNC share). Half a path is worse than none: a + // relative `rm -rf` matches nothing and exits 0, so the user believes the + // cache is clear and hits the same failure. No match instead sends them to + // the whole-cache fallback below, which does unstick them. + const match = + /(?:^|[\s'"])((?:[A-Za-z]:[/\\]|\\\\|\/)[^'"]*?[/\\]_npx[/\\][^\s/\\'"]+)/.exec( + message, + ); if (match) return match[1]; return process.platform === 'win32' ? path.join( From acc76bea22ff23ace91ef7655e5d84e6a9b96f93 Mon Sep 17 00:00:00 2001 From: "posthog[bot]" <206114724+posthog[bot]@users.noreply.github.com> Date: Thu, 3 Sep 2026 12:22:37 +0000 Subject: [PATCH 7/7] fix(errors): label the Windows cache removal command per shell MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `process.platform` names the OS, not the shell. `Remove-Item` is a PowerShell cmdlet, so a Windows user at a Command Prompt who pastes the printed line gets "'Remove-Item' is not recognized" — the recovery step fails even when the path is right, which is the one thing this message exists to prevent. Windows now prints both forms, labelled and aligned, and the user picks the one their prompt understands: PowerShell: Remove-Item -Recurse -Force "C:\...\_npx\abc" Command Prompt: rmdir /s /q "C:\...\_npx\abc" Detecting the shell was rejected: the repo carries no shell detection today (every other process.platform check picks a filesystem location, where the OS is the right axis), and inferring it from ComSpec or MSYSTEM is a new concern for no gain over letting the reader choose a labelled line. Non-Windows output is byte-identical. Covered by a test that stubs process.platform to win32, matching the pattern in the MCP client tests. Generated-By: PostHog Desktop Task-Id: 398deef2-51b6-41a3-9b19-10e368612efe --- .../errors/__tests__/module-missing.test.ts | 34 ++++++++++++++++++- src/lib/errors/module-missing.ts | 13 +++++-- 2 files changed, 43 insertions(+), 4 deletions(-) diff --git a/src/lib/errors/__tests__/module-missing.test.ts b/src/lib/errors/__tests__/module-missing.test.ts index 7cc893f4d..f5b2c2f12 100644 --- a/src/lib/errors/__tests__/module-missing.test.ts +++ b/src/lib/errors/__tests__/module-missing.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { formatModuleMissingMessage, isModuleNotFoundError, @@ -91,4 +91,36 @@ describe('formatModuleMissingMessage', () => { '_npx', ); }); + + // `process.platform` is the OS, not the shell: a Windows user may be at a + // Command Prompt, where `Remove-Item` is not a command at all. + describe('on Windows', () => { + const originalPlatform = process.platform; + + beforeEach(() => { + Object.defineProperty(process, 'platform', { + value: 'win32', + writable: true, + }); + }); + + afterEach(() => { + Object.defineProperty(process, 'platform', { + value: originalPlatform, + writable: true, + }); + }); + + it('labels a removal command for each shell', () => { + const dir = + 'C:\\Users\\John Smith\\AppData\\Local\\npm-cache\\_npx\\abc1234567890def'; + const message = formatModuleMissingMessage( + `Cannot find package 'chalk' imported from ${dir}\\node_modules\\x.js`, + ); + expect(message).toContain( + `PowerShell: Remove-Item -Recurse -Force "${dir}"`, + ); + expect(message).toContain(`Command Prompt: rmdir /s /q "${dir}"`); + }); + }); }); diff --git a/src/lib/errors/module-missing.ts b/src/lib/errors/module-missing.ts index c59b98d31..ef0adddba 100644 --- a/src/lib/errors/module-missing.ts +++ b/src/lib/errors/module-missing.ts @@ -45,10 +45,17 @@ function npxCacheDir(message: string): string { export function formatModuleMissingMessage(message: string): string { const dir = npxCacheDir(message); + // `process.platform` names the OS, not the shell. A Windows user may be at a + // PowerShell or a Command Prompt, and `Remove-Item` is only a command in the + // first, so a single unlabelled line errors out for half of them. Both are + // printed, labelled, and the user picks the one their prompt understands. const remove = process.platform === 'win32' - ? `Remove-Item -Recurse -Force "${dir}"` - : `rm -rf "${dir}"`; + ? [ + `PowerShell: Remove-Item -Recurse -Force "${dir}"`, + `Command Prompt: rmdir /s /q "${dir}"`, + ] + : [`rm -rf "${dir}"`]; return [ 'Broken npx download', '', @@ -57,7 +64,7 @@ export function formatModuleMissingMessage(message: string): string { '', 'Delete the cached download:', '', - ` ${remove}`, + ...remove.map((line) => ` ${line}`), '', // Not a literal rerun command: every wizard command reaches this message, // so naming the default flow would send an `audit` user into an install.