feat(agent-core-v2): surface a machine-key note from capability installs

CapabilityEntry.install now resolves an optional note exposed through
CapabilityInstallProgress.note (wire-visible). The webbridge entry
returns 'user-skill-migrated' when it replaces a pre-existing
user-source skill (from the official installer) with the plugin-managed
copy — clients can localize the migration instead of the skill silently
disappearing from the user's directory.
This commit is contained in:
qer 2026-07-30 19:44:24 +08:00
parent 431c1f12ec
commit 1781895546
7 changed files with 58 additions and 15 deletions

View file

@ -126,13 +126,16 @@ export class CapabilityService extends Disposable implements ICapabilityService
// Fire-and-forget: progress and errors are surfaced through polling.
void (async () => {
try {
await entry.install((step, percent) => {
const note = await entry.install((step, percent) => {
this.installProgress.set(
entry.id,
percent === undefined ? { running: true, step } : { running: true, step, percent },
);
});
this.installProgress.set(entry.id, { running: false });
this.installProgress.set(
entry.id,
note === undefined ? { running: false } : { running: false, note },
);
} catch (error) {
this.installProgress.set(entry.id, {
running: false,

View file

@ -183,7 +183,7 @@ export function createKimiCuEntry(ctx: CapabilityEntryContext): CapabilityEntry
}
}
async function install(report: CapabilityInstallReporter): Promise<void> {
async function install(report: CapabilityInstallReporter): Promise<string | undefined> {
if (!supported) {
throw new Error(`kimi-cu is only supported on macOS (current: ${ctx.platform})`);
}
@ -240,6 +240,7 @@ export function createKimiCuEntry(ctx: CapabilityEntryContext): CapabilityEntry
await runCommand(ctx.hostProcess, appBin, ['request-permissions', '--ax', '--screen'], {
timeout: PERMISSIONS_TIMEOUT_MS,
});
return undefined;
} finally {
await rm(workDir, { recursive: true, force: true }).catch(() => undefined);
}

View file

@ -141,7 +141,7 @@ export function createKimiWebbridgeEntry(ctx: CapabilityEntryContext): Capabilit
throw new Error(`WebBridge daemon did not come up on ${baseUrl} — check ~/.kimi-webbridge/logs`);
}
async function install(report: CapabilityInstallReporter): Promise<void> {
async function install(report: CapabilityInstallReporter): Promise<string | undefined> {
const asset = binaryAssetName(ctx.platform, ctx.arch);
if (asset === undefined) {
throw new Error(`kimi-webbridge is not supported on ${ctx.platform}/${ctx.arch}`);
@ -188,8 +188,12 @@ export function createKimiWebbridgeEntry(ctx: CapabilityEntryContext): Capabilit
report('skill');
await ctx.plugins.installPlugin({ source: PLUGIN_ZIP_URL });
// Un-shadow the plugin copy: the user-source skill (priority 20) wins
// over the plugin source (priority 5) on name collisions.
// over the plugin source (priority 5) on name collisions. Surface the
// migration so clients can tell the user their manually-installed skill
// is now managed as a plugin.
const hadUserSourceSkill = await exists(userSourceSkillDir);
await rm(userSourceSkillDir, { recursive: true, force: true });
return hadUserSourceSkill ? 'user-skill-migrated' : undefined;
}
return {

View file

@ -40,6 +40,13 @@ export interface CapabilityInstallProgress {
readonly percent?: number;
/** Set when the last install attempt failed; cleared on the next attempt. */
readonly error?: string;
/**
* Machine-key note from the last completed install (e.g.
* 'user-skill-migrated' a pre-existing user-source skill was replaced by
* the plugin-managed copy). Clients localize it; cleared on the next
* attempt.
*/
readonly note?: string;
}
export interface CapabilityDetectResult {
@ -79,5 +86,9 @@ export interface CapabilityEntry {
*/
readonly wiringStepId: string;
detect(): Promise<CapabilityDetectResult>;
install(report: CapabilityInstallReporter): Promise<void>;
/**
* Resolves with an optional machine-key note surfaced through
* `CapabilityInstallProgress.note` (e.g. 'user-skill-migrated').
*/
install(report: CapabilityInstallReporter): Promise<string | undefined>;
}

View file

@ -21,7 +21,7 @@ function fakeEntry(overrides: {
supported?: boolean;
wiringStepId?: string;
detect?: CapabilityDetectResult;
install?: (report: CapabilityInstallReporter) => Promise<void>;
install?: (report: CapabilityInstallReporter) => Promise<string | undefined>;
}): CapabilityEntry {
return {
id: overrides.id,
@ -33,7 +33,7 @@ function fakeEntry(overrides: {
Promise.resolve(
overrides.detect ?? { steps: [{ id: 'plugin', state: 'ok' }] },
),
install: overrides.install ?? (() => Promise.resolve()),
install: overrides.install ?? (() => Promise.resolve(undefined)),
};
}
@ -164,8 +164,8 @@ describe('CapabilityService', () => {
id: 'kimi-cu',
install: (report) => {
report('download', 42);
return new Promise<void>((resolve) => {
release = resolve;
return new Promise<string | undefined>((resolve) => {
release = () => resolve(undefined);
});
},
}),
@ -199,6 +199,22 @@ describe('CapabilityService', () => {
expect.unreachable('install never settled');
});
it('surfaces an install note from the entry through progress', async () => {
const service = fakeService([
fakeEntry({
id: 'kimi-cu',
install: () => Promise.resolve('user-skill-migrated'),
}),
]);
await service.installCapability('kimi-cu');
for (let i = 0; i < 50; i += 1) {
const status = await service.getCapability('kimi-cu');
if (!status.install.running) break;
await new Promise((resolve) => setTimeout(resolve, 10));
}
expect((await service.getCapability('kimi-cu')).install.note).toBe('user-skill-migrated');
});
it('surfaces install errors through progress until the next attempt', async () => {
let attempts = 0;
const service = fakeService([
@ -206,7 +222,9 @@ describe('CapabilityService', () => {
id: 'kimi-cu',
install: () => {
attempts += 1;
return attempts === 1 ? Promise.reject(new Error('boom')) : Promise.resolve();
return attempts === 1
? Promise.reject(new Error('boom'))
: Promise.resolve(undefined);
},
}),
]);
@ -237,7 +255,7 @@ describe('CapabilityService shelf-install hook', () => {
id: 'kimi-cu' | 'kimi-webbridge';
wiringStepId?: string;
steps: Array<{ id: string; state: 'ok' | 'missing'; optional?: boolean }>;
install?: () => Promise<void>;
install?: () => Promise<string | undefined>;
}) {
const state = { steps: opts.steps };
let installs = 0;
@ -247,7 +265,7 @@ describe('CapabilityService shelf-install hook', () => {
detect: { steps: state.steps },
install: () => {
installs += 1;
return (opts.install ?? (() => Promise.resolve()))();
return (opts.install ?? (() => Promise.resolve(undefined)))();
},
});
// Re-read the mutable step list on every detect.

View file

@ -215,7 +215,10 @@ describe('kimi-webbridge entry', () => {
makeCtx({ plugins: plugins.service, hostProcess: host.service, fetchImpl }),
);
await entry.install((step, percent) => reports.push([step, percent]));
const note = await entry.install((step, percent) => reports.push([step, percent]));
// Migration note surfaced (the pre-existing user skill was replaced).
expect(note).toBe('user-skill-migrated');
// Binary downloaded into place and made executable.
const binPath = path.join(root, 'user-home', '.kimi-webbridge', 'bin', 'kimi-webbridge');
@ -244,8 +247,10 @@ describe('kimi-webbridge entry', () => {
makeCtx({ plugins: plugins.service, hostProcess: host.service, fetchImpl }),
);
await entry.install(() => {});
const note = await entry.install(() => {});
expect(host.calls).toEqual([]);
// No pre-existing user skill → no migration note.
expect(note).toBeUndefined();
});
it('rejects install on unsupported platforms before any side effect', async () => {

View file

@ -19,6 +19,7 @@ export const capabilityInstallProgressSchema = z.object({
step: z.string().optional(),
percent: z.number().min(0).max(100).optional(),
error: z.string().optional(),
note: z.string().optional(),
});
export type CapabilityInstallProgressWire = z.infer<typeof capabilityInstallProgressSchema>;