Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/watch-user-skill-roots.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Watch the user-level skill roots (`~/.kimi-code/skills` and `~/.agents/skills`) so the workspace skill catalog refreshes automatically when skills are created, modified, or deleted while the daemon is running — no restart or manual reload needed.
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
import { join } from 'pathe';

import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
import { Disposable } from '#/_base/di/lifecycle';
import { Disposable, DisposableStore } from '#/_base/di/lifecycle';
import { Emitter, type Event } from '#/_base/event';
import { LifecycleScope } from '#/app/scopes';
import { ScopeActivation, registerScopedService } from '#/_base/di/scope';
import { IBootstrapService } from '#/app/bootstrap/bootstrap';
import { IConfigService } from '#/app/config/config';
import { TimeoutTimer } from '#/_base/utils/timer';
import { subtreeWatchFilter } from '#/_base/utils/paths';
import { watch } from '#human/utils/watch';

import {
MERGE_ALL_AVAILABLE_SKILLS_SECTION,
Expand All @@ -21,13 +26,18 @@ export interface IUserFileSkillSource extends ISkillSource {
export const IUserFileSkillSource: ServiceIdentifier<IUserFileSkillSource> =
createDecorator<IUserFileSkillSource>('userFileSkillSource');

const WATCH_DEBOUNCE_MS = 200;

export class UserFileSkillSource extends Disposable implements IUserFileSkillSource {
declare readonly _serviceBrand: undefined;

readonly id = 'user';
readonly priority = SKILL_SOURCE_PRIORITY.user;
private readonly onDidChangeEmitter = this._register(new Emitter<void>());
readonly onDidChange: Event<void> = this.onDidChangeEmitter.event;
private readonly watchDebounce = this._register(new TimeoutTimer());
private readonly watchResources = this._register(new DisposableStore());
private watchReady: Promise<void> = Promise.resolve();

constructor(
@ISkillDiscovery private readonly discovery: ISkillDiscovery,
Expand All @@ -40,9 +50,13 @@ export class UserFileSkillSource extends Disposable implements IUserFileSkillSou
if (event.domain === MERGE_ALL_AVAILABLE_SKILLS_SECTION) this.onDidChangeEmitter.fire();
}),
);
if ((this.bootstrap.args.skillDirs?.length ?? 0) === 0) {
this.watchUserSkillRoots();
}
}

async load(): Promise<SkillContribution> {
await this.watchReady;
if ((this.bootstrap.args.skillDirs?.length ?? 0) > 0) {
return { skills: [] };
}
Expand All @@ -53,6 +67,34 @@ export class UserFileSkillSource extends Disposable implements IUserFileSkillSou
await userRoots(this.bootstrap.homeDir, this.bootstrap.osHomeDir, { mergeAllAvailableSkills }),
);
}

private watchUserSkillRoots(): void {
const candidatesByBase = new Map<string, string[]>();
const addTarget = (base: string, candidate: string): void => {
const candidates = candidatesByBase.get(base);
if (candidates === undefined) candidatesByBase.set(base, [candidate]);
else candidates.push(candidate);
};
addTarget(this.bootstrap.homeDir, join(this.bootstrap.homeDir, 'skills'));
addTarget(this.bootstrap.osHomeDir, join(this.bootstrap.osHomeDir, '.agents', 'skills'));
const ready: Promise<void>[] = [];
for (const [base, candidates] of candidatesByBase) {
const handle = watch(base, {
ignored: subtreeWatchFilter(base, candidates),
signal: true,
Comment on lines +82 to +84

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 Badge Watch resolved user skill roots behind symlinks

On the chokidar-backed platforms, this watches only the parent base while the shared watcher is configured with followSymlinks: false. If skills or .agents/skills is a symlink—common when managing home configuration through a dotfiles directory—initial discovery succeeds because userRoots() resolves the symlink, but subsequent edits in its target produce no event and the catalog remains stale; the resolved discovered roots also need watcher coverage.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

确认为已知边界,本 PR 不修复。followSymlinks: false 是共享 watch 基础设施(#human/utils/watch)的统一配置,项目级 WorkspaceRootSkillSource 对符号链接的 .agents/skills 也存在同样限制,并非本 PR 引入的回归。正确修法是在每次 load 后对 realpath 出的 scannedRoots 轮换 watch(类似项目级的 watch handoff),属于基础设施增强,影响面限于用 dotfiles 符号链接管理 skills 目录的用户(重启/重载后首次扫描仍正常)。建议记录为后续 issue。

— Mira(代 liukx0205 分诊处理)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Acknowledged as a known boundary, not fixing in this PR. followSymlinks: false is the shared watch infrastructure's uniform setting (#human/utils/watch), and the project-level WorkspaceRootSkillSource has the same limitation for symlinked .agents/skills — not a regression introduced here. The proper fix is rotating watches onto the realpath-d scannedRoots after each load (similar to the project-level watch handoff), which is infrastructure-level work. Impact is limited to dotfiles-symlinked skill dirs, and the initial scan on (re)load still resolves symlinks correctly. Suggest tracking as a follow-up issue.

});
this.watchResources.add(handle);
this.watchResources.add(
handle.onDidChange(() => {
this.watchDebounce.cancelAndSet(() => {
this.onDidChangeEmitter.fire();
}, WATCH_DEBOUNCE_MS);
}),
);
ready.push(handle.ready);
}
this.watchReady = Promise.all(ready).then(() => undefined);
}
}

registerScopedService(
Expand Down
3 changes: 2 additions & 1 deletion packages/agent-core-v2/test/app/bootstrap/stubs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export function stubBootstrap(
homeDir = '/tmp/kimi-home',
env: NodeJS.ProcessEnv = {},
args: HostArgsInput = {},
osHomeDir = '/home/test',
): IBootstrapService {
const scopes: Record<PersistenceScopeName, string> = {
config: '',
Expand All @@ -31,7 +32,7 @@ export function stubBootstrap(
platform: 'linux',
arch: 'x64',
cwd: '/tmp',
osHomeDir: '/home/test',
osHomeDir,
homeDir,
configPath: `${homeDir}/config.toml`,
configKey: 'config.toml',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1086,4 +1086,194 @@ describe('WorkspaceSkillCatalogService', () => {
await rm(workDir, { recursive: true, force: true });
}
}, 15000);

it('watches both user-level skill roots and prunes unrelated paths', async () => {
const host = createScopedTestHost([
stubPair(IFlagService, stubFlag(true)),
stubPair(IBootstrapService, stubBootstrap('/home', {}, {}, '/os-home')),
stubPair(IConfigService, configStub()),
stubPair(IPluginService, pluginStub()),
stubPair(ILogService, stubLog()),
stubPair(ISkillDiscovery, new FileSkillDiscovery(stubLog())),
]);
const workspace = host.child('program', 'w1', [
stubPair(IWorkspaceContext, workspaceContextStub('/work')),
]);

try {
const catalog = workspace.accessor.get(IWorkspaceSkillCatalog);
await catalog.load();

const homeCall = watchMockState.calls.find((call) => call.path === '/home');
const osCall = watchMockState.calls.find((call) => call.path === '/os-home');
expect(homeCall).toBeDefined();
expect(osCall).toBeDefined();
expect(homeCall?.options?.ignored?.('/home/skills/demo/SKILL.md')).toBe(false);
expect(homeCall?.options?.ignored?.('/home/sessions/s1/state.json')).toBe(true);
expect(osCall?.options?.ignored?.('/os-home/.agents/skills/demo/SKILL.md')).toBe(false);
expect(osCall?.options?.ignored?.('/os-home/Downloads/x.zip')).toBe(true);
} finally {
host.dispose();
}
});

it('merges both skill-root candidates into one watch when homeDir equals osHomeDir', async () => {
const host = createScopedTestHost([
stubPair(IFlagService, stubFlag(true)),
stubPair(IBootstrapService, stubBootstrap('/home', {}, {}, '/home')),
stubPair(IConfigService, configStub()),
stubPair(IPluginService, pluginStub()),
stubPair(ILogService, stubLog()),
stubPair(ISkillDiscovery, new FileSkillDiscovery(stubLog())),
]);
const workspace = host.child('program', 'w1', [
stubPair(IWorkspaceContext, workspaceContextStub('/work')),
]);

try {
const catalog = workspace.accessor.get(IWorkspaceSkillCatalog);
await catalog.load();

const homeCalls = watchMockState.calls.filter((call) => call.path === '/home');
expect(homeCalls).toHaveLength(1);
const ignored = homeCalls[0]?.options?.ignored;
expect(ignored?.('/home/skills/demo/SKILL.md')).toBe(false);
expect(ignored?.('/home/.agents/skills/demo/SKILL.md')).toBe(false);
expect(ignored?.('/home/sessions/s1/state.json')).toBe(true);
} finally {
host.dispose();
}
});

it('does not watch the user skill roots when explicit skillDirs are set', async () => {
const host = createScopedTestHost([
stubPair(IFlagService, stubFlag(true)),
stubPair(IBootstrapService, stubBootstrap('/home', {}, { skillDirs: ['/explicit'] }, '/os-home')),
stubPair(IConfigService, configStub()),
stubPair(IPluginService, pluginStub()),
stubPair(ILogService, stubLog()),
stubPair(ISkillDiscovery, new FileSkillDiscovery(stubLog())),
]);
const workspace = host.child('program', 'w1', [
stubPair(IWorkspaceContext, workspaceContextStub('/work')),
]);

try {
const catalog = workspace.accessor.get(IWorkspaceSkillCatalog);
await catalog.load();

const watchedPaths = watchMockState.calls.map((call) => call.path);
expect(watchedPaths).not.toContain('/home');
expect(watchedPaths).not.toContain('/os-home');
} finally {
host.dispose();
}
});

it('disposes the user root watches when the app scope is disposed', async () => {
const handles: { disposed: boolean }[] = [];
watchMockState.factory = () => {
const handle = {
ready: Promise.resolve(),
onDidChange: () => ({ dispose: () => {} }),
disposed: false,
dispose: () => {
handle.disposed = true;
},
};
handles.push(handle);
return handle;
};
const host = createScopedTestHost([
stubPair(IFlagService, stubFlag(true)),
stubPair(IBootstrapService, bootstrapStub),
stubPair(IConfigService, configStub()),
stubPair(IPluginService, pluginStub()),
stubPair(ILogService, stubLog()),
stubPair(ISkillDiscovery, new FileSkillDiscovery(stubLog())),
]);
const workspace = host.child('program', 'w1', [
stubPair(IWorkspaceContext, workspaceContextStub('/work')),
]);

const catalog = workspace.accessor.get(IWorkspaceSkillCatalog);
await catalog.load();
expect(handles.length).toBeGreaterThan(0);

host.dispose();
expect(handles.every((handle) => handle.disposed)).toBe(true);
});

it('rescans the user source when skills appear, change and disappear under the user roots', async () => {
watchMockState.mode = 'real';
const homeDir = await mkdtemp(join(tmpdir(), 'skill-user-watch-'));
const osHomeDir = await mkdtemp(join(tmpdir(), 'skill-os-watch-'));
const host = createScopedTestHost([
stubPair(IFlagService, stubFlag(true)),
stubPair(IBootstrapService, stubBootstrap(homeDir, {}, {}, osHomeDir)),
stubPair(IConfigService, configStub()),
stubPair(IPluginService, pluginStub()),
stubPair(ILogService, stubLog()),
stubPair(ISkillDiscovery, new FileSkillDiscovery(stubLog())),
]);
const workspace = host.child('program', 'w1', [
stubPair(IWorkspaceContext, workspaceContextStub('/work')),
]);
const writeSkill = (dir: string, description: string) =>
writeFile(
join(dir, 'SKILL.md'),
`---\nname: watched-user-skill\ndescription: ${description}\n---\nbody`,
'utf8',
);

try {
const catalog = workspace.accessor.get(IWorkspaceSkillCatalog);
await catalog.load();
expect(catalog.catalog.getSkill('watched-user-skill')).toBeUndefined();

const waitForUserChange = (): Promise<string> => {
const refreshed = new Promise<string>((resolvePromise) => {
const d = catalog.onDidChange((sourceId) => {
if (sourceId !== 'user') return;
d.dispose();
resolvePromise(sourceId);
});
});
const timedOut = new Promise<never>((_resolve, reject) => {
setTimeout(() => {
reject(new Error('user watch refresh timed out'));
}, 10000);
});
return Promise.race([refreshed, timedOut]);
};

const created = waitForUserChange();
const skillDir = join(homeDir, 'skills', 'watched-user-skill');
await mkdir(skillDir, { recursive: true });
await writeSkill(skillDir, 'v1');
await created;
expect(catalog.catalog.getSkill('watched-user-skill')?.description).toBe('v1');

const modified = waitForUserChange();
await writeSkill(skillDir, 'v2');
await modified;
expect(catalog.catalog.getSkill('watched-user-skill')?.description).toBe('v2');

const deleted = waitForUserChange();
await rm(skillDir, { recursive: true, force: true });
await deleted;
expect(catalog.catalog.getSkill('watched-user-skill')).toBeUndefined();

const osCreated = waitForUserChange();
const osSkillDir = join(osHomeDir, '.agents', 'skills', 'watched-user-skill');
await mkdir(osSkillDir, { recursive: true });
await writeSkill(osSkillDir, 'os');
await osCreated;
expect(catalog.catalog.getSkill('watched-user-skill')?.description).toBe('os');
} finally {
host.dispose();
await rm(homeDir, { recursive: true, force: true });
await rm(osHomeDir, { recursive: true, force: true });
}
}, 30000);
});
Loading