Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions packages/coding-agent/changes.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
# Local fork changes

## 2026-09-02 - Load skill files concurrently at startup

- `loadSkills` / `loadSkillsFromDir` are now async and read skill files concurrently while preserving skill and diagnostic order exactly. `resource-loader`'s `extendResources` / `updateSkillsFromPaths` and the app-server skills loader await them; startup with many skills no longer serializes disk reads.

## 2026-09-02 - Keep the startup indicator through the interactive-mode import

- The startup loading indicator now stays visible through the dynamic import of the interactive-mode module graph (the largest cold-start cost after resource loading) instead of stopping before it, which left a blank terminal that looked frozen.

## 2026-09-01 - Acknowledge RPC abort before quiesce

- The RPC `abort` command now acknowledges immediately after dispatching the abort signal, while observing quiesce failures through the existing `rpc_error` event path.
Expand Down
2 changes: 1 addition & 1 deletion packages/coding-agent/src/core/agent-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6637,7 +6637,7 @@ export class AgentSession {
hookPaths: this.buildExtensionResourcePaths(hookPaths),
};

this._resourceLoader.extendResources(extensionPaths);
await this._resourceLoader.extendResources(extensionPaths);
if (skillPaths.length > 0 || promptPaths.length > 0 || themePaths.length > 0) {
this._baseSystemPrompt = this._rebuildSystemPrompt(this.getActiveToolNames());
this.agent.state.systemPrompt = this._baseSystemPrompt;
Expand Down
12 changes: 6 additions & 6 deletions packages/coding-agent/src/core/resource-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ export interface ResourceLoader {
getAppendSystemPrompt(): string[];
getLoadedHookSources?(): LoadedHookSources;
getAppendSystemPromptSources(): Array<{ path: string }>;
extendResources(paths: ResourceExtensionPaths): void;
extendResources(paths: ResourceExtensionPaths): Promise<void>;
reload(options?: ResourceLoaderReloadOptions): Promise<void>;
}

Expand Down Expand Up @@ -520,7 +520,7 @@ export class DefaultResourceLoader implements ResourceLoader {
return this.appendSystemPromptSourcePaths.map((path) => ({ path }));
}

extendResources(paths: ResourceExtensionPaths): void {
async extendResources(paths: ResourceExtensionPaths): Promise<void> {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When callers invoke extendResources() without awaiting it, newly discovered skills are not available on the next statement. Update every caller and the existing resource-loader tests to await the new Promise-returning API before reading resources.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/coding-agent/src/core/resource-loader.ts, line 523:

<comment>When callers invoke `extendResources()` without awaiting it, newly discovered skills are not available on the next statement. Update every caller and the existing resource-loader tests to await the new Promise-returning API before reading resources.</comment>

<file context>
@@ -520,7 +520,7 @@ export class DefaultResourceLoader implements ResourceLoader {
 	}
 
-	extendResources(paths: ResourceExtensionPaths): void {
+	async extendResources(paths: ResourceExtensionPaths): Promise<void> {
 		const skillPaths = this.normalizeExtensionPaths(paths.skillPaths ?? []);
 		const promptPaths = this.normalizeExtensionPaths(paths.promptPaths ?? []);
</file context>

const skillPaths = this.normalizeExtensionPaths(paths.skillPaths ?? []);
const promptPaths = this.normalizeExtensionPaths(paths.promptPaths ?? []);
const themePaths = this.normalizeExtensionPaths(paths.themePaths ?? []);
Expand All @@ -541,7 +541,7 @@ export class DefaultResourceLoader implements ResourceLoader {
this.lastSkillPaths,
skillPaths.map((entry) => entry.path),
);
this.updateSkillsFromPaths(this.lastSkillPaths, this.resourceMetadataByPath);
await this.updateSkillsFromPaths(this.lastSkillPaths, this.resourceMetadataByPath);
}

if (promptPaths.length > 0) {
Expand Down Expand Up @@ -687,7 +687,7 @@ export class DefaultResourceLoader implements ResourceLoader {
: this.mergePaths([...cliEnabledSkills, ...enabledSkills], this.additionalSkillPaths);

this.lastSkillPaths = skillPaths;
this.updateSkillsFromPaths(skillPaths, metadataByPath);
await this.updateSkillsFromPaths(skillPaths, metadataByPath);
time("skills", "extensions");
for (const p of this.additionalSkillPaths) {
if (isLocalPath(p)) {
Expand Down Expand Up @@ -953,12 +953,12 @@ export class DefaultResourceLoader implements ResourceLoader {
};
}

private updateSkillsFromPaths(skillPaths: string[], metadataByPath?: Map<string, PathMetadata>): void {
private async updateSkillsFromPaths(skillPaths: string[], metadataByPath?: Map<string, PathMetadata>): Promise<void> {
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
let skillsResult: { skills: Skill[]; diagnostics: ResourceDiagnostic[] };
if (this.noSkills && skillPaths.length === 0) {
skillsResult = { skills: [], diagnostics: [] };
} else {
skillsResult = loadSkills({
skillsResult = await loadSkills({
cwd: this.cwd,
agentDir: this.agentDir,
skillPaths,
Expand Down
65 changes: 37 additions & 28 deletions packages/coding-agent/src/core/skills.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { existsSync, readdirSync, readFileSync, statSync } from "fs";
import { existsSync, readdirSync, statSync } from "fs";
import { readFile } from "fs/promises";
import ignore from "ignore";
import { basename, dirname, join, relative, resolve, sep } from "path";
import { CONFIG_DIR_NAME, getAgentDir } from "../config.ts";
Expand Down Expand Up @@ -165,7 +166,7 @@ function createSkillSourceInfo(filePath: string, baseDir: string, source: string
* - otherwise, load direct .md children in the root
* - recurse into subdirectories to find SKILL.md
*/
export function loadSkillsFromDir(options: LoadSkillsFromDirOptions): LoadSkillsResult {
export async function loadSkillsFromDir(options: LoadSkillsFromDirOptions): Promise<LoadSkillsResult> {
const { dir, source } = options;
return loadSkillsFromDirInternal(dir, source, true);
}
Expand All @@ -176,12 +177,12 @@ function loadSkillsFromDirInternal(
includeRootFiles: boolean,
ignoreMatcher?: IgnoreMatcher,
rootDir?: string,
): LoadSkillsResult {
): Promise<LoadSkillsResult> {
const skills: Skill[] = [];
const diagnostics: ResourceDiagnostic[] = [];

if (!existsSync(dir)) {
return { skills, diagnostics };
return Promise.resolve({ skills, diagnostics });
}

const root = rootDir ?? dir;
Expand Down Expand Up @@ -212,14 +213,17 @@ function loadSkillsFromDirInternal(
continue;
}

const result = loadSkillFromFile(fullPath, source);
if (result.skill) {
skills.push(result.skill);
}
diagnostics.push(...result.diagnostics);
return { skills, diagnostics };
// A root SKILL.md is the whole skill root: do not recurse.
return loadSkillFromFile(fullPath, source).then((result) => ({
skills: result.skill ? [result.skill] : [],
diagnostics: result.diagnostics,
}));
}

// Collect the work in traversal order, then run the (IO-bound) file reads
// concurrently so a large skill tree does not serialize disk access. Order
// and diagnostics stay identical to the sequential walk.
const pending: Array<Promise<LoadSkillsResult>> = [];
for (const entry of entries) {
if (entry.name.startsWith(".")) {
continue;
Expand Down Expand Up @@ -253,37 +257,42 @@ function loadSkillsFromDirInternal(
}

if (isDirectory) {
const subResult = loadSkillsFromDirInternal(fullPath, source, false, ig, root);
skills.push(...subResult.skills);
diagnostics.push(...subResult.diagnostics);
pending.push(loadSkillsFromDirInternal(fullPath, source, false, ig, root));
continue;
}

if (!isFile || !includeRootFiles || !entry.name.endsWith(".md")) {
continue;
}

const result = loadSkillFromFile(fullPath, source);
if (result.skill) {
skills.push(result.skill);
}
diagnostics.push(...result.diagnostics);
pending.push(loadSkillFromFile(fullPath, source).then((result) => ({
skills: result.skill ? [result.skill] : [],
diagnostics: result.diagnostics,
})));
}
} catch {}

return { skills, diagnostics };
return Promise.all(pending).then((results) => {
for (const result of results) {
skills.push(...result.skills);
diagnostics.push(...result.diagnostics);
}
return { skills, diagnostics };
});
} catch {
return Promise.resolve({ skills, diagnostics });
}
}

function loadSkillFromFile(
async function loadSkillFromFile(
filePath: string,
source: string,
): { skill: Skill | null; diagnostics: ResourceDiagnostic[] } {
): Promise<{ skill: Skill | null; diagnostics: ResourceDiagnostic[] }> {
const diagnostics: ResourceDiagnostic[] = [];
const isDeclaredSkill = basename(filePath) === "SKILL.md";

let rawContent: string;
try {
rawContent = readFileSync(filePath, "utf-8");
rawContent = await readFile(filePath, "utf-8");
} catch (error) {
const message = error instanceof Error ? error.message : "failed to read skill file";
diagnostics.push({ type: "warning", message, path: filePath });
Expand Down Expand Up @@ -404,7 +413,7 @@ export interface LoadSkillsOptions {
* Load skills from all configured locations.
* Returns skills and any validation diagnostics.
*/
export function loadSkills(options: LoadSkillsOptions): LoadSkillsResult {
export async function loadSkills(options: LoadSkillsOptions): Promise<LoadSkillsResult> {
const { agentDir, skillPaths, includeDefaults } = options;

// Resolve agentDir - if not provided, use default from config
Expand Down Expand Up @@ -448,8 +457,8 @@ export function loadSkills(options: LoadSkillsOptions): LoadSkillsResult {
}

if (includeDefaults) {
addSkills(loadSkillsFromDirInternal(join(resolvedAgentDir, "skills"), "user", true));
addSkills(loadSkillsFromDirInternal(resolve(resolvedCwd, CONFIG_DIR_NAME, "skills"), "project", true));
addSkills(await loadSkillsFromDirInternal(join(resolvedAgentDir, "skills"), "user", true));
addSkills(await loadSkillsFromDirInternal(resolve(resolvedCwd, CONFIG_DIR_NAME, "skills"), "project", true));
}

const userSkillsDir = join(resolvedAgentDir, "skills");
Expand Down Expand Up @@ -483,9 +492,9 @@ export function loadSkills(options: LoadSkillsOptions): LoadSkillsResult {
const stats = statSync(resolvedPath);
const source = getSource(resolvedPath);
if (stats.isDirectory()) {
addSkills(loadSkillsFromDirInternal(resolvedPath, source, true));
addSkills(await loadSkillsFromDirInternal(resolvedPath, source, true));
} else if (stats.isFile() && resolvedPath.endsWith(".md")) {
const result = loadSkillFromFile(resolvedPath, source);
const result = await loadSkillFromFile(resolvedPath, source);
if (result.skill) {
addSkills({ skills: [result.skill], diagnostics: result.diagnostics });
} else {
Expand Down
8 changes: 5 additions & 3 deletions packages/coding-agent/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1117,8 +1117,6 @@ export async function main(args: string[], options?: MainOptions) {
cwd: sessionManager.getCwd(),
agentDir,
sessionManager,
}).finally(() => {
startupLoadingIndicator.stop();
});
time("createAgentSessionRuntime");
let selectedRuntime = runtime;
Expand Down Expand Up @@ -1222,7 +1220,11 @@ export async function main(args: string[], options?: MainOptions) {
} else if (appMode === "interactive") {
// Keep the TUI graph out of headless RPC children. This is intentionally at the
// mode seam: interactive startup still loads the same module before first use.
const { InteractiveMode } = await import("./modes/interactive/interactive-mode.ts");
const { InteractiveMode } = await import("./modes/interactive/interactive-mode.ts").finally(() => {
// The startup indicator must stop on success AND failure: an import rejection
// must not leave the spinner running with the cursor hidden.
startupLoadingIndicator.stop();
});
const interactiveMode = new InteractiveMode(selectedRuntime, {
migratedProviders,
modelFallbackMessage,
Expand Down
4 changes: 2 additions & 2 deletions packages/coding-agent/src/modes/app-server/server/skills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,11 +131,11 @@ function findLoadedLoader(

async function createLoader(cwd: string, options: SkillsListEntryOptions): Promise<SkillResourceLoader> {
if (options.resourceLoaderFactory) return options.resourceLoaderFactory(cwd);
let loaded = loadSkills({ cwd, agentDir: options.agentDir, skillPaths: [], includeDefaults: true });
let loaded = await loadSkills({ cwd, agentDir: options.agentDir, skillPaths: [], includeDefaults: true });
return {
getSkills: () => loaded,
reload: async () => {
loaded = loadSkills({ cwd, agentDir: options.agentDir, skillPaths: [], includeDefaults: true });
loaded = await loadSkills({ cwd, agentDir: options.agentDir, skillPaths: [], includeDefaults: true });
},
};
}
Expand Down
2 changes: 1 addition & 1 deletion packages/coding-agent/test/imagegen-skill-gating.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ describe("imagegen skill contribution", () => {

const resources = await runner.emitResourcesDiscover(cwd, "startup");
const skillPaths = resources.skillPaths.map((entry) => entry.path);
const loaded = loadSkills({ cwd, agentDir: join(cwd, "agent"), skillPaths, includeDefaults: false });
const loaded = await loadSkills({ cwd, agentDir: join(cwd, "agent"), skillPaths, includeDefaults: false });
const prompt = await runner.emitBeforeAgentStart("draw an image", undefined, "base", { cwd });

expect(skillPaths).toHaveLength(1);
Expand Down
6 changes: 3 additions & 3 deletions packages/coding-agent/test/resource-loader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -716,7 +716,7 @@ Extra prompt content`,
const loader = new DefaultResourceLoader({ cwd, agentDir });
await loader.reload();

loader.extendResources({
await loader.extendResources({
skillPaths: [
{
path: extraSkillDir,
Expand Down Expand Up @@ -770,7 +770,7 @@ Extra content`,
const loader = new DefaultResourceLoader({ cwd, agentDir });
await loader.reload();

loader.extendResources({
await loader.extendResources({
skillPaths: [
{
path: pathToFileURL(extraSkillDir).href,
Expand Down Expand Up @@ -865,7 +865,7 @@ Extension prompt content`,
scope: "temporary",
origin: "top-level",
} as const;
loader.extendResources({
await loader.extendResources({
skillPaths: [{ path: extensionSkillDir, metadata: extensionMetadata }],
promptPaths: [{ path: extensionPromptsDir, metadata: extensionMetadata }],
themePaths: [{ path: extensionThemesDir, metadata: extensionMetadata }],
Expand Down
Loading