diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts
index 6e6b503df..e2aa9280e 100644
--- a/apps/server/src/provider/Layers/CodexAdapter.test.ts
+++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts
@@ -366,6 +366,45 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => {
}),
);
+ it.effect("maps snake_case plan statuses to canonical turn.plan.updated events", () =>
+ Effect.gen(function* () {
+ const adapter = yield* CodexAdapter;
+ const firstEventFiber = yield* Stream.runHead(adapter.streamEvents).pipe(Effect.forkChild);
+
+ lifecycleManager.emit("event", {
+ id: asEventId("evt-plan-updated"),
+ kind: "notification",
+ provider: "codex",
+ createdAt: new Date().toISOString(),
+ method: "turn/plan/updated",
+ threadId: asThreadId("thread-1"),
+ turnId: asTurnId("turn-1"),
+ payload: {
+ explanation: "Working through the plan",
+ plan: [
+ { step: "Inspect files", status: "completed" },
+ { step: "Apply patch", status: "in_progress" },
+ ],
+ },
+ } satisfies ProviderEvent);
+
+ const firstEvent = yield* Fiber.join(firstEventFiber);
+
+ assert.equal(firstEvent._tag, "Some");
+ if (firstEvent._tag !== "Some") {
+ return;
+ }
+ assert.equal(firstEvent.value.type, "turn.plan.updated");
+ if (firstEvent.value.type !== "turn.plan.updated") {
+ return;
+ }
+ assert.deepStrictEqual(firstEvent.value.payload.plan, [
+ { step: "Inspect files", status: "completed" },
+ { step: "Apply patch", status: "inProgress" },
+ ]);
+ }),
+ );
+
it.effect("maps plan deltas to canonical proposed-plan delta events", () =>
Effect.gen(function* () {
const adapter = yield* CodexAdapter;
diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts
index da341bb70..a2b98b30f 100644
--- a/apps/server/src/provider/Layers/CodexAdapter.ts
+++ b/apps/server/src/provider/Layers/CodexAdapter.ts
@@ -443,6 +443,16 @@ function extractProposedPlanMarkdown(text: string | undefined): string | undefin
return planMarkdown && planMarkdown.length > 0 ? planMarkdown : undefined;
}
+function normalizePlanStepStatus(status: unknown): "pending" | "inProgress" | "completed" {
+ if (status === "completed") {
+ return "completed";
+ }
+ if (status === "inProgress" || status === "in_progress") {
+ return "inProgress";
+ }
+ return "pending";
+}
+
function asRuntimeItemId(itemId: ProviderItemId): RuntimeItemId {
return RuntimeItemId.makeUnsafe(itemId);
}
@@ -833,10 +843,7 @@ function mapToRuntimeEvents(
.filter((entry): entry is Record => entry !== undefined)
.map((entry) => ({
step: asString(entry.step) ?? "step",
- status:
- entry.status === "completed" || entry.status === "inProgress"
- ? entry.status
- : "pending",
+ status: normalizePlanStepStatus(entry.status),
})),
},
},
diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx
index f274ed113..ee5eb2503 100644
--- a/apps/web/src/components/ChatView.tsx
+++ b/apps/web/src/components/ChatView.tsx
@@ -874,6 +874,11 @@ export default function ChatView({ threadId }: ChatViewProps) {
() => deriveActivePlanState(threadActivities, activeLatestTurn?.turnId ?? undefined),
[activeLatestTurn?.turnId, threadActivities],
);
+ const activePlanTurnId = activePlan?.turnId ?? null;
+ const activePendingUserInputRequestId = activePendingUserInput?.requestId ?? null;
+ const hasPendingPlanFeedback =
+ activePendingUserInputRequestId !== null &&
+ (activePlanTurnId !== null || interactionMode === "plan");
const showPlanFollowUpPrompt =
pendingUserInputs.length === 0 &&
interactionMode === "plan" &&
@@ -928,6 +933,23 @@ export default function ChatView({ threadId }: ChatViewProps) {
activePendingUserInput?.requestId,
activePendingProgress?.activeQuestion?.id,
]);
+ useEffect(() => {
+ if (!hasPendingPlanFeedback) {
+ return;
+ }
+ const turnKey =
+ activePlanTurnId ?? sidebarProposedPlan?.turnId ?? activeLatestTurn?.turnId ?? null;
+ if (!turnKey || planSidebarDismissedForTurnRef.current === turnKey) {
+ return;
+ }
+ setPlanSidebarOpen(true);
+ }, [
+ activeLatestTurn?.turnId,
+ activePlanTurnId,
+ hasPendingPlanFeedback,
+ sidebarProposedPlan?.turnId,
+ ]);
+
useEffect(() => {
attachmentPreviewHandoffByMessageIdRef.current = attachmentPreviewHandoffByMessageId;
}, [attachmentPreviewHandoffByMessageId]);
@@ -1921,7 +1943,8 @@ export default function ChatView({ threadId }: ChatViewProps) {
const togglePlanSidebar = useCallback(() => {
setPlanSidebarOpen((open) => {
if (open) {
- const turnKey = activePlan?.turnId ?? sidebarProposedPlan?.turnId ?? null;
+ const turnKey =
+ activePlan?.turnId ?? sidebarProposedPlan?.turnId ?? activeLatestTurn?.turnId ?? null;
if (turnKey) {
planSidebarDismissedForTurnRef.current = turnKey;
}
@@ -1930,7 +1953,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
}
return !open;
});
- }, [activePlan?.turnId, sidebarProposedPlan?.turnId]);
+ }, [activeLatestTurn?.turnId, activePlan?.turnId, sidebarProposedPlan?.turnId]);
const persistThreadSettingsForNextTurn = useCallback(
async (input: {
@@ -5048,14 +5071,24 @@ export default function ChatView({ threadId }: ChatViewProps) {
{planSidebarOpen ? (
{
setPlanSidebarOpen(false);
// Track that the user explicitly dismissed for this turn so auto-open won't fight them.
- const turnKey = activePlan?.turnId ?? sidebarProposedPlan?.turnId ?? null;
+ const turnKey =
+ activePlan?.turnId ??
+ sidebarProposedPlan?.turnId ??
+ activeLatestTurn?.turnId ??
+ null;
if (turnKey) {
planSidebarDismissedForTurnRef.current = turnKey;
}
diff --git a/apps/web/src/components/PlanChecklist.tsx b/apps/web/src/components/PlanChecklist.tsx
index 827c50247..be2f494c8 100644
--- a/apps/web/src/components/PlanChecklist.tsx
+++ b/apps/web/src/components/PlanChecklist.tsx
@@ -1,6 +1,7 @@
import { memo } from "react";
import { CheckIcon, LoaderIcon } from "lucide-react";
import { cn } from "~/lib/utils";
+import { Badge } from "./ui/badge";
// ---------------------------------------------------------------------------
// Types
@@ -11,6 +12,12 @@ export interface PlanChecklistItemData {
text: string;
/** Execution status. */
status: "pending" | "inProgress" | "completed";
+ /** Optional supporting note shown below the step. */
+ note?: string;
+ /** Optional status label override shown beside the step. */
+ statusText?: string;
+ /** Optional badge tone for the status label override. */
+ statusTone?: "success" | "info" | "warning";
}
interface PlanChecklistProps {
@@ -50,6 +57,14 @@ const PlanChecklist = memo(function PlanChecklist({
{items.length === 1 ? "To-do" : "To-dos"}
·
{completionMode}
+ {completedCount > 0 ? (
+ <>
+ ·
+
+ {completedCount === items.length ? "All done" : `${completedCount} done`}
+
+ >
+ ) : null}
@@ -57,7 +72,7 @@ const PlanChecklist = memo(function PlanChecklist({
{items.map((item, index) => (
{/* Progress summary */}
- {completedCount > 0 && completedCount < items.length ? (
+ {completedCount > 0 ? (
-
- {completedCount}/{items.length}
+
+ {completedCount === items.length ? "Done" : `${completedCount}/${items.length} done`}
) : null}
@@ -101,6 +116,8 @@ const PlanChecklistRow = memo(function PlanChecklistRow({
isLast: boolean;
live: boolean;
}) {
+ const statusBadge = resolveChecklistStatusBadge(item);
+
return (
{/* Status indicator */}
-
+
{/* Text */}
-
- {item.text}
-
+
+
+ {item.text}
+
+ {item.note ? (
+
+ {item.note}
+
+ ) : null}
+
+
+ {statusBadge ? (
+
+ {statusBadge.label}
+
+ ) : null}
{/* Item number */}
@@ -144,9 +183,11 @@ const PlanChecklistRow = memo(function PlanChecklistRow({
function ChecklistStatusIndicator({
status,
+ statusTone,
live,
}: {
status: PlanChecklistItemData["status"];
+ statusTone?: PlanChecklistItemData["statusTone"];
live: boolean;
}) {
if (status === "completed") {
@@ -158,6 +199,13 @@ function ChecklistStatusIndicator({
}
if (status === "inProgress") {
+ if (statusTone === "warning") {
+ return (
+
+ ?
+
+ );
+ }
return (
{live ? (
@@ -177,5 +225,26 @@ function ChecklistStatusIndicator({
);
}
+function resolveChecklistStatusBadge(
+ item: PlanChecklistItemData,
+): { label: string; variant: "success" | "info" | "warning" } | null {
+ if (item.statusText && item.statusTone) {
+ return {
+ label: item.statusText,
+ variant: item.statusTone,
+ };
+ }
+
+ if (item.status === "completed") {
+ return { label: "Done", variant: "success" };
+ }
+
+ if (item.status === "inProgress") {
+ return { label: "Working", variant: "info" };
+ }
+
+ return null;
+}
+
export default PlanChecklist;
export type { PlanChecklistProps };
diff --git a/apps/web/src/components/PlanSidebar.tsx b/apps/web/src/components/PlanSidebar.tsx
index 43f561116..94f0d8587 100644
--- a/apps/web/src/components/PlanSidebar.tsx
+++ b/apps/web/src/components/PlanSidebar.tsx
@@ -1,11 +1,11 @@
import { memo, useState, useCallback, useRef, useEffect, useMemo } from "react";
import { type TimestampFormat } from "../appSettings";
import { Button } from "./ui/button";
+import { Badge } from "./ui/badge";
import { ScrollArea } from "./ui/scroll-area";
import ChatMarkdown from "./ChatMarkdown";
import { ChevronDownIcon, ChevronRightIcon, EllipsisIcon, PanelRightCloseIcon } from "lucide-react";
-import type { ActivePlanState } from "../session-logic";
-import type { LatestProposedPlanState } from "../session-logic";
+import type { ActivePlanState, LatestProposedPlanState, PendingUserInput } from "../session-logic";
import {
proposedPlanTitle,
buildProposedPlanMarkdownFilename,
@@ -14,12 +14,14 @@ import {
stripDisplayedPlanMarkdown,
} from "../proposedPlan";
import { extractPlanChecklistItems } from "../planChecklist";
-import PlanChecklist from "./PlanChecklist";
+import PlanChecklist, { type PlanChecklistItemData } from "./PlanChecklist";
import { Menu, MenuItem, MenuPopup, MenuTrigger } from "./ui/menu";
import { readNativeApi } from "~/nativeApi";
import { toastManager } from "./ui/toast";
import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
import { getLocalStorageItem, setLocalStorageItem } from "~/hooks/useLocalStorage";
+import { type PendingUserInputProgress } from "../pendingUserInput";
+import { cn } from "~/lib/utils";
import { Schema } from "effect";
const PLAN_SIDEBAR_WIDTH_STORAGE_KEY = "plan_sidebar_width";
@@ -33,6 +35,12 @@ interface PlanSidebarProps {
markdownCwd: string | undefined;
workspaceRoot: string | undefined;
timestampFormat: TimestampFormat;
+ activePendingUserInput: PendingUserInput | null;
+ activePendingProgress: PendingUserInputProgress | null;
+ activePendingIsResponding: boolean;
+ onSelectPendingUserInputOption: (questionId: string, optionLabel: string) => void;
+ onAdvancePendingUserInput: () => void;
+ onFocusComposer: () => void;
onClose: () => void;
}
@@ -139,16 +147,44 @@ function useResizablePlanSidebar() {
};
}
+function findFeedbackStepIndex(
+ steps: ActivePlanState["steps"] | undefined,
+ feedbackRequested: boolean,
+): number | null {
+ if (!feedbackRequested || !steps || steps.length === 0) {
+ return null;
+ }
+
+ const inProgressIndex = steps.findIndex((step) => step.status === "inProgress");
+ if (inProgressIndex !== -1) {
+ return inProgressIndex;
+ }
+
+ const nextPendingIndex = steps.findIndex((step) => step.status !== "completed");
+ if (nextPendingIndex !== -1) {
+ return nextPendingIndex;
+ }
+
+ return steps.length - 1;
+}
+
const PlanSidebar = memo(function PlanSidebar({
activePlan,
activeProposedPlan,
markdownCwd,
workspaceRoot,
+ activePendingUserInput,
+ activePendingProgress,
+ activePendingIsResponding,
+ onSelectPendingUserInputOption,
+ onAdvancePendingUserInput,
+ onFocusComposer,
onClose,
}: PlanSidebarProps) {
const hasActiveSteps = (activePlan?.steps.length ?? 0) > 0;
const [proposedPlanExpanded, setProposedPlanExpanded] = useState(false);
const [isSavingToWorkspace, setIsSavingToWorkspace] = useState(false);
+ const pendingAdvanceTimerRef = useRef(null);
const { copyToClipboard, isCopied } = useCopyToClipboard();
const { width, railProps } = useResizablePlanSidebar();
const progress = usePlanProgress(activePlan?.steps);
@@ -156,14 +192,39 @@ const PlanSidebar = memo(function PlanSidebar({
const planMarkdown = activeProposedPlan?.planMarkdown ?? null;
const displayedPlanMarkdown = planMarkdown ? stripDisplayedPlanMarkdown(planMarkdown) : null;
const planTitle = planMarkdown ? proposedPlanTitle(planMarkdown) : null;
+ const feedbackQuestion = activePendingProgress?.activeQuestion ?? null;
+ const feedbackStepIndex = useMemo(
+ () => findFeedbackStepIndex(activePlan?.steps, activePendingUserInput !== null),
+ [activePendingUserInput, activePlan?.steps],
+ );
+ const feedbackStep =
+ feedbackStepIndex !== null && activePlan?.steps[feedbackStepIndex]
+ ? activePlan.steps[feedbackStepIndex]
+ : null;
// Derive checklist items: prefer live execution steps; fall back to markdown extraction.
// Always normalised to { text, status } for the PlanChecklist component.
- const checklistItems = useMemo<
- Array<{ text: string; status: "pending" | "inProgress" | "completed" }>
- >(() => {
+ const checklistItems = useMemo(() => {
if (hasActiveSteps && activePlan) {
- return activePlan.steps.map((s) => ({ text: s.step, status: s.status }));
+ return activePlan.steps.map((step, index) => {
+ const isFeedbackStep =
+ feedbackQuestion !== null && feedbackStepIndex !== null && index === feedbackStepIndex;
+ return {
+ text: step.step,
+ status: step.status,
+ ...(isFeedbackStep
+ ? {
+ note: activePendingIsResponding
+ ? "Submitting your answer."
+ : activePendingProgress?.usingCustomAnswer
+ ? "Custom answer drafted in the composer. Submit when ready."
+ : "Waiting for your feedback to continue.",
+ statusText: activePendingIsResponding ? "Sending" : "Needs input",
+ statusTone: "warning" as const,
+ }
+ : {}),
+ };
+ });
}
if (planMarkdown) {
const extracted = extractPlanChecklistItems(planMarkdown);
@@ -175,7 +236,15 @@ const PlanSidebar = memo(function PlanSidebar({
}
}
return [];
- }, [hasActiveSteps, activePlan, planMarkdown]);
+ }, [
+ hasActiveSteps,
+ activePlan,
+ planMarkdown,
+ feedbackQuestion,
+ feedbackStepIndex,
+ activePendingIsResponding,
+ activePendingProgress?.usingCustomAnswer,
+ ]);
const hasChecklist = checklistItems.length > 0;
@@ -186,6 +255,14 @@ const PlanSidebar = memo(function PlanSidebar({
}
}, [hasChecklist, planMarkdown]);
+ useEffect(() => {
+ return () => {
+ if (pendingAdvanceTimerRef.current !== null) {
+ window.clearTimeout(pendingAdvanceTimerRef.current);
+ }
+ };
+ }, []);
+
const handleCopyPlan = useCallback(() => {
if (!planMarkdown) return;
copyToClipboard(planMarkdown);
@@ -228,6 +305,33 @@ const PlanSidebar = memo(function PlanSidebar({
);
}, [planMarkdown, workspaceRoot]);
+ const handleSelectPendingOption = useCallback(
+ (questionId: string, optionLabel: string) => {
+ onSelectPendingUserInputOption(questionId, optionLabel);
+ if (pendingAdvanceTimerRef.current !== null) {
+ window.clearTimeout(pendingAdvanceTimerRef.current);
+ }
+ pendingAdvanceTimerRef.current = window.setTimeout(() => {
+ pendingAdvanceTimerRef.current = null;
+ onAdvancePendingUserInput();
+ }, 200);
+ },
+ [onAdvancePendingUserInput, onSelectPendingUserInputOption],
+ );
+
+ const handlePendingAction = useCallback(() => {
+ if (activePendingProgress?.usingCustomAnswer && activePendingProgress.canAdvance) {
+ onAdvancePendingUserInput();
+ return;
+ }
+ onFocusComposer();
+ }, [
+ activePendingProgress?.canAdvance,
+ activePendingProgress?.usingCustomAnswer,
+ onAdvancePendingUserInput,
+ onFocusComposer,
+ ]);
+
return (
-
- {progress.completed}/{progress.total}
+ 0
+ ? "text-emerald-700/80 dark:text-emerald-300/80"
+ : "text-muted-foreground/50",
+ )}
+ >
+ {progress.completed === progress.total
+ ? "Done"
+ : `${progress.completed}/${progress.total} done`}
) : null}
@@ -309,9 +422,104 @@ const PlanSidebar = memo(function PlanSidebar({
{/* Explanation */}
{activePlan?.explanation ? (
-
- {activePlan.explanation}
-
+
+
+ Latest Note
+
+
+ {activePlan.explanation}
+
+
+ ) : null}
+
+ {activePendingUserInput && feedbackQuestion ? (
+
+
+
+ {feedbackStepIndex !== null ? `Step ${feedbackStepIndex + 1}` : "Plan input"}
+
+ {activePendingUserInput.questions.length > 1 ? (
+
+ {(activePendingProgress?.questionIndex ?? 0) + 1}/
+ {activePendingUserInput.questions.length} questions
+
+ ) : null}
+
+ {feedbackQuestion.header}
+
+
+ {feedbackStep ? (
+
+ {feedbackStep.step}
+
+ ) : null}
+
+ {feedbackQuestion.question}
+
+
+ {feedbackQuestion.options.map((option, index) => {
+ const isSelected =
+ !activePendingProgress?.usingCustomAnswer &&
+ activePendingProgress?.selectedOptionLabel === option.label;
+ return (
+
handleSelectPendingOption(feedbackQuestion.id, option.label)}
+ className={cn(
+ "group flex w-full items-center gap-3 rounded-lg border px-3 py-2 text-left transition-all duration-150",
+ isSelected
+ ? "border-amber-400/40 bg-amber-400/10 text-foreground"
+ : "border-transparent bg-background/45 text-foreground/80 hover:border-amber-300/20 hover:bg-background/70",
+ activePendingIsResponding && "cursor-not-allowed opacity-50",
+ )}
+ >
+ {index < 9 ? (
+
+ {index + 1}
+
+ ) : null}
+
+ {option.label}
+ {option.description && option.description !== option.label ? (
+
+ {option.description}
+
+ ) : null}
+
+
+ );
+ })}
+
+
+
+ {activePendingProgress?.usingCustomAnswer
+ ? "Custom answer ready in the composer."
+ : "Choose an option here or type a custom answer in the composer."}
+
+
+ {activePendingProgress?.usingCustomAnswer && activePendingProgress.canAdvance
+ ? activePendingProgress.isLastQuestion
+ ? "Submit answer"
+ : "Next question"
+ : "Use composer"}
+
+
+
) : null}
{/* Checklist (primary view) */}
diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts
index 02bb423d4..b813f9ec1 100644
--- a/apps/web/src/session-logic.test.ts
+++ b/apps/web/src/session-logic.test.ts
@@ -326,7 +326,7 @@ describe("deriveActivePlanState", () => {
turnId: "turn-1",
payload: {
explanation: "Refined plan",
- plan: [{ step: "Implement Codex user input", status: "inProgress" }],
+ plan: [{ step: "Implement Codex user input", status: "in_progress" }],
},
}),
];
diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts
index 250fbef16..c09928b2f 100644
--- a/apps/web/src/session-logic.ts
+++ b/apps/web/src/session-logic.ts
@@ -366,11 +366,9 @@ export function deriveActivePlanState(
if (typeof record.step !== "string") {
return null;
}
- const status =
- record.status === "completed" || record.status === "inProgress" ? record.status : "pending";
return {
step: record.step,
- status,
+ status: normalizePlanStepStatus(record.status),
};
})
.filter(
@@ -394,6 +392,16 @@ export function deriveActivePlanState(
};
}
+function normalizePlanStepStatus(status: unknown): "pending" | "inProgress" | "completed" {
+ if (status === "completed") {
+ return "completed";
+ }
+ if (status === "inProgress" || status === "in_progress") {
+ return "inProgress";
+ }
+ return "pending";
+}
+
export function findLatestProposedPlan(
proposedPlans: ReadonlyArray
,
latestTurnId: TurnId | string | null | undefined,
diff --git a/package.json b/package.json
index 4994f3d98..a0356c34f 100644
--- a/package.json
+++ b/package.json
@@ -54,7 +54,7 @@
"clean": "rm -rf node_modules apps/*/node_modules packages/*/node_modules apps/*/dist apps/*/dist-electron packages/*/dist .turbo apps/*/.turbo packages/*/.turbo",
"sync:vscode-icons": "node scripts/sync-vscode-icons.mjs",
"regenerate:brand-assets": "python3 scripts/generate-brand-assets.py",
- "prepare": "husky && node scripts/patch-effect-language-service.ts"
+ "prepare": "husky && node scripts/patch-effect-language-service.ts && node scripts/patch-effect-smol-peer-installs.mjs"
},
"devDependencies": {
"@types/node": "catalog:",
diff --git a/scripts/patch-effect-smol-peer-installs.mjs b/scripts/patch-effect-smol-peer-installs.mjs
new file mode 100644
index 000000000..e67873d1e
--- /dev/null
+++ b/scripts/patch-effect-smol-peer-installs.mjs
@@ -0,0 +1,64 @@
+import fs from "node:fs";
+import fsp from "node:fs/promises";
+import path from "node:path";
+
+const rootDir = path.resolve(import.meta.dirname, "..");
+const rootEffectDir = path.join(rootDir, "node_modules", "effect");
+
+const nestedEffectDirs = [
+ path.join(rootDir, "node_modules", "@effect", "platform-node", "node_modules", "effect"),
+ path.join(rootDir, "node_modules", "@effect", "platform-node-shared", "node_modules", "effect"),
+ path.join(rootDir, "node_modules", "@effect", "sql-sqlite-bun", "node_modules", "effect"),
+ path.join(rootDir, "node_modules", "@effect", "vitest", "node_modules", "effect"),
+];
+
+async function pathExists(targetPath) {
+ try {
+ await fsp.lstat(targetPath);
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+async function isSymlinkToRootEffect(targetPath) {
+ try {
+ const stat = await fsp.lstat(targetPath);
+ if (!stat.isSymbolicLink()) {
+ return false;
+ }
+ const realTarget = await fsp.realpath(targetPath);
+ const realRootEffect = await fsp.realpath(rootEffectDir);
+ return realTarget === realRootEffect;
+ } catch {
+ return false;
+ }
+}
+
+async function patchNestedEffectInstall(targetPath) {
+ const parentDir = path.dirname(targetPath);
+ await fsp.mkdir(parentDir, { recursive: true });
+
+ if (await isSymlinkToRootEffect(targetPath)) {
+ return;
+ }
+
+ if (await pathExists(targetPath)) {
+ await fsp.rm(targetPath, { recursive: true, force: true });
+ }
+
+ const symlinkType = process.platform === "win32" ? "junction" : "dir";
+ await fsp.symlink(rootEffectDir, targetPath, symlinkType);
+ console.log(`patched nested effect install: ${path.relative(rootDir, targetPath)}`);
+}
+
+async function main() {
+ if (!fs.existsSync(rootEffectDir)) {
+ console.warn("skipping nested effect patch because root effect install is missing");
+ return;
+ }
+
+ await Promise.all(nestedEffectDirs.map((targetPath) => patchNestedEffectInstall(targetPath)));
+}
+
+await main();