From f9327b8f26ec32a08da9989dd691da54d8945828 Mon Sep 17 00:00:00 2001 From: betegon Date: Fri, 27 Feb 2026 20:25:12 +0100 Subject: [PATCH 1/4] perf(init): pre-compute directory listing before first API call Scans the project directory locally and sends the listing with the initial workflow request. This lets the server's discover-context step skip its list-dir suspend, saving one full round-trip. Co-Authored-By: Claude Opus 4.6 --- src/lib/init/local-ops.ts | 13 +++++++++++++ src/lib/init/wizard-runner.ts | 19 ++++++++++++++++--- 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/src/lib/init/local-ops.ts b/src/lib/init/local-ops.ts index 429bea2f..ec201091 100644 --- a/src/lib/init/local-ops.ts +++ b/src/lib/init/local-ops.ts @@ -141,6 +141,19 @@ function safePath(cwd: string, relative: string): string { return resolved; } +/** + * Pre-compute directory listing before the first API call. + * Uses the same parameters the server's discover-context step would request. + */ +export function precomputeDirListing(directory: string): LocalOpResult { + return listDir({ + type: "local-op", + operation: "list-dir", + cwd: directory, + params: { path: ".", recursive: true, maxDepth: 3, maxEntries: 500 }, + }); +} + export async function handleLocalOp( payload: LocalOpPayload, options: WizardOptions diff --git a/src/lib/init/wizard-runner.ts b/src/lib/init/wizard-runner.ts index ea38246f..842e1329 100644 --- a/src/lib/init/wizard-runner.ts +++ b/src/lib/init/wizard-runner.ts @@ -21,7 +21,7 @@ import { } from "./constants.js"; import { formatError, formatResult } from "./formatters.js"; import { handleInteractive } from "./interactive.js"; -import { handleLocalOp } from "./local-ops.js"; +import { handleLocalOp, precomputeDirListing } from "./local-ops.js"; import type { InteractivePayload, LocalOpPayload, @@ -153,9 +153,22 @@ export async function runWizard(options: WizardOptions): Promise { let result: WorkflowRunResult; try { - spin.start("Connecting to wizard..."); + spin.start("Scanning project..."); + const listing = precomputeDirListing(directory); + const dirListing = + ( + listing.data as { + entries: Array<{ + name: string; + path: string; + type: "file" | "directory"; + }>; + } + )?.entries ?? []; + + spin.message("Connecting to wizard..."); result = (await run.startAsync({ - inputData: { directory, force, yes, dryRun, features }, + inputData: { directory, force, yes, dryRun, features, dirListing }, tracingOptions, })) as WorkflowRunResult; } catch (err) { From 3d3256c9f14a78edc0f1122d0e8d6eed6742afc2 Mon Sep 17 00:00:00 2001 From: betegon Date: Tue, 3 Mar 2026 17:16:42 +0100 Subject: [PATCH 2/4] refactor: add DirEntry type and precomputeDirListing returning DirEntry[] directly Extract inline entry shape into a named DirEntry type and add precomputeDirListing() that returns DirEntry[] instead of LocalOpResult, so callers get the entries array directly without type assertions. Co-Authored-By: Claude Opus 4.6 --- src/lib/init/local-ops.ts | 12 +++----- src/lib/init/types.ts | 6 ++++ src/lib/init/wizard-runner.ts | 16 ++-------- test/lib/init/local-ops.test.ts | 47 +++++++++++++++++++++++++++++ test/lib/init/wizard-runner.test.ts | 5 +++ 5 files changed, 66 insertions(+), 20 deletions(-) diff --git a/src/lib/init/local-ops.ts b/src/lib/init/local-ops.ts index 697a1311..bcc9da81 100644 --- a/src/lib/init/local-ops.ts +++ b/src/lib/init/local-ops.ts @@ -15,6 +15,7 @@ import { } from "./constants.js"; import type { ApplyPatchsetPayload, + DirEntry, FileExistsBatchPayload, ListDirPayload, LocalOpPayload, @@ -169,13 +170,14 @@ function safePath(cwd: string, relative: string): string { * Pre-compute directory listing before the first API call. * Uses the same parameters the server's discover-context step would request. */ -export function precomputeDirListing(directory: string): LocalOpResult { - return listDir({ +export function precomputeDirListing(directory: string): DirEntry[] { + const result = listDir({ type: "local-op", operation: "list-dir", cwd: directory, params: { path: ".", recursive: true, maxDepth: 3, maxEntries: 500 }, }); + return (result.data as { entries?: DirEntry[] })?.entries ?? []; } export async function handleLocalOp( @@ -231,11 +233,7 @@ function listDir(payload: ListDirPayload): LocalOpResult { const maxEntries = params.maxEntries ?? 500; const recursive = params.recursive ?? false; - const entries: Array<{ - name: string; - path: string; - type: "file" | "directory"; - }> = []; + const entries: DirEntry[] = []; // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: walking the directory tree is a complex operation function walk(dir: string, depth: number): void { diff --git a/src/lib/init/types.ts b/src/lib/init/types.ts index d44f7278..66dcb94b 100644 --- a/src/lib/init/types.ts +++ b/src/lib/init/types.ts @@ -1,3 +1,9 @@ +export type DirEntry = { + name: string; + path: string; + type: "file" | "directory"; +}; + export type WizardOptions = { directory: string; force: boolean; diff --git a/src/lib/init/wizard-runner.ts b/src/lib/init/wizard-runner.ts index 83ff3209..695ed315 100644 --- a/src/lib/init/wizard-runner.ts +++ b/src/lib/init/wizard-runner.ts @@ -150,22 +150,12 @@ export async function runWizard(options: WizardOptions): Promise { const spin = spinner(); + spin.start("Scanning project..."); + const dirListing = precomputeDirListing(directory); + let run: Awaited>; let result: WorkflowRunResult; try { - spin.start("Scanning project..."); - const listing = precomputeDirListing(directory); - const dirListing = - ( - listing.data as { - entries: Array<{ - name: string; - path: string; - type: "file" | "directory"; - }>; - } - )?.entries ?? []; - spin.message("Connecting to wizard..."); run = await workflow.createRun(); result = (await run.startAsync({ diff --git a/test/lib/init/local-ops.test.ts b/test/lib/init/local-ops.test.ts index c4aa39ef..e1768004 100644 --- a/test/lib/init/local-ops.test.ts +++ b/test/lib/init/local-ops.test.ts @@ -3,6 +3,7 @@ import fs, { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { handleLocalOp, + precomputeDirListing, validateCommand, } from "../../../src/lib/init/local-ops.js"; import type { @@ -768,3 +769,49 @@ describe("handleLocalOp", () => { }); }); }); + +describe("precomputeDirListing", () => { + let testDir: string; + + beforeEach(() => { + testDir = mkdtempSync(join("/tmp", "precompute-test-")); + }); + + afterEach(() => { + rmSync(testDir, { recursive: true, force: true }); + }); + + test("returns DirEntry[] directly", () => { + writeFileSync(join(testDir, "app.ts"), "x"); + mkdirSync(join(testDir, "src")); + + const entries = precomputeDirListing(testDir); + + expect(Array.isArray(entries)).toBe(true); + expect(entries.length).toBeGreaterThanOrEqual(2); + + const names = entries.map((e) => e.name).sort(); + expect(names).toContain("app.ts"); + expect(names).toContain("src"); + + const file = entries.find((e) => e.name === "app.ts"); + expect(file?.type).toBe("file"); + + const dir = entries.find((e) => e.name === "src"); + expect(dir?.type).toBe("directory"); + }); + + test("returns empty array for non-existent directory", () => { + const entries = precomputeDirListing(join(testDir, "nope")); + expect(entries).toEqual([]); + }); + + test("recursively lists nested entries", () => { + mkdirSync(join(testDir, "a")); + writeFileSync(join(testDir, "a", "nested.ts"), "x"); + + const entries = precomputeDirListing(testDir); + const paths = entries.map((e) => e.path); + expect(paths).toContain(join("a", "nested.ts")); + }); +}); diff --git a/test/lib/init/wizard-runner.test.ts b/test/lib/init/wizard-runner.test.ts index e12348b5..7a911a55 100644 --- a/test/lib/init/wizard-runner.test.ts +++ b/test/lib/init/wizard-runner.test.ts @@ -66,6 +66,7 @@ let formatBannerSpy: ReturnType; let formatResultSpy: ReturnType; let formatErrorSpy: ReturnType; let handleLocalOpSpy: ReturnType; +let precomputeDirListingSpy: ReturnType; let handleInteractiveSpy: ReturnType; // MastraClient @@ -143,6 +144,9 @@ beforeEach(() => { ok: true, data: { results: [] }, }); + precomputeDirListingSpy = spyOn(ops, "precomputeDirListing").mockReturnValue( + [] + ); handleInteractiveSpy = spyOn(inter, "handleInteractive").mockResolvedValue({ action: "continue", }); @@ -169,6 +173,7 @@ afterEach(() => { formatResultSpy.mockRestore(); formatErrorSpy.mockRestore(); handleLocalOpSpy.mockRestore(); + precomputeDirListingSpy.mockRestore(); handleInteractiveSpy.mockRestore(); stderrSpy.mockRestore(); From 553fd7ca831bb278d9b038f6e698b116bfa8a908 Mon Sep 17 00:00:00 2001 From: betegon Date: Tue, 3 Mar 2026 17:33:35 +0100 Subject: [PATCH 3/4] fix: add precomputeDirListing to isolated test mock Co-Authored-By: Claude Opus 4.6 --- test/isolated/init-wizard-runner.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/test/isolated/init-wizard-runner.test.ts b/test/isolated/init-wizard-runner.test.ts index 2b7f739c..0f2ee6fd 100644 --- a/test/isolated/init-wizard-runner.test.ts +++ b/test/isolated/init-wizard-runner.test.ts @@ -40,6 +40,7 @@ const mockHandleLocalOp = mock(() => ); mock.module("../../src/lib/init/local-ops.js", () => ({ handleLocalOp: mockHandleLocalOp, + precomputeDirListing: () => [], validateCommand: () => { /* noop mock */ }, From 9c0b6e6f27f5e1b66447b3e430edb094b269cb29 Mon Sep 17 00:00:00 2001 From: betegon Date: Tue, 3 Mar 2026 17:43:41 +0100 Subject: [PATCH 4/4] feat(init): send _prevPhases in resume data for cross-phase caching Co-Authored-By: Claude Opus 4.6 --- src/lib/init/wizard-runner.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/lib/init/wizard-runner.ts b/src/lib/init/wizard-runner.ts index 695ed315..358a8e91 100644 --- a/src/lib/init/wizard-runner.ts +++ b/src/lib/init/wizard-runner.ts @@ -50,7 +50,8 @@ function nextPhase( async function handleSuspendedStep( ctx: StepContext, - stepPhases: Map + stepPhases: Map, + stepHistory: Map[]> ): Promise> { const { payload, stepId, spin, options } = ctx; const { type: payloadType, operation } = payload as { @@ -65,9 +66,14 @@ async function handleSuspendedStep( const localResult = await handleLocalOp(payload as LocalOpPayload, options); + const history = stepHistory.get(stepId) ?? []; + history.push(localResult); + stepHistory.set(stepId, history); + return { ...localResult, _phase: nextPhase(stepPhases, stepId, ["read-files", "analyze", "done"]), + _prevPhases: history.slice(0, -1), }; } @@ -171,6 +177,7 @@ export async function runWizard(options: WizardOptions): Promise { } const stepPhases = new Map(); + const stepHistory = new Map[]>(); try { while (result.status === "suspended") { @@ -188,7 +195,8 @@ export async function runWizard(options: WizardOptions): Promise { const resumeData = await handleSuspendedStep( { payload: extracted.payload, stepId: extracted.stepId, spin, options }, - stepPhases + stepPhases, + stepHistory ); result = (await run.resumeAsync({