feat: remove legacy OpenCode runtime

Cut product flows over to Coding/Pi and retain only the migration-owned v1 boundary. Promote supported native optional packages because electron-builder omitted pnpm transitive optional closure from the packaged ASAR.
This commit is contained in:
2026-08-24 12:17:43 +08:00
parent 61fede2b4d
commit 5a275b93a7
199 changed files with 1330 additions and 64725 deletions

View File

@@ -0,0 +1,267 @@
import path from 'node:path';
import {
isProjectAgentAvatarDataUrl,
isProjectType,
validateAgentNames,
type ProjectAgentConfig,
type ProjectConfig,
type ProjectType,
} from '../../shared/project-config';
import type { ProductModelRef } from '../coding-runtime/contracts';
import type { ProviderAccount } from '../shared/providers/types';
const RETIRED_PROJECT_SKILL_IDS = new Set(['game-engine']);
function normalizeStringList(value: unknown): string[] {
if (!Array.isArray(value)) return [];
return [...new Set(value
.filter((item): item is string => typeof item === 'string')
.map((item) => item.trim())
.filter(Boolean))];
}
function normalizeProjectSkillIds(value: unknown): string[] {
return normalizeStringList(value).filter((skillId) => !RETIRED_PROJECT_SKILL_IDS.has(skillId));
}
function normalizeProjectType(value: unknown): ProjectType {
if (value === undefined) return 'custom';
if (isProjectType(value)) return value;
throw new Error('Invalid project type');
}
function normalizeAgent(value: unknown): ProjectAgentConfig | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
const raw = value as Partial<ProjectAgentConfig>;
const id = typeof raw.id === 'string' ? raw.id.trim() : '';
const name = typeof raw.name === 'string' ? raw.name.trim() : '';
if (!/^[a-z0-9][a-z0-9-]{0,63}$/.test(id)) return null;
const rawResponsibility = raw.responsibility;
const responsibility = rawResponsibility && typeof rawResponsibility === 'object'
? rawResponsibility as Partial<ProjectAgentConfig['responsibility']>
: {};
return {
id,
avatarId: typeof raw.avatarId === 'string' && /^avatar-(0[1-9]|1[0-6])$/.test(raw.avatarId)
? raw.avatarId
: 'avatar-01',
avatarDataUrl: isProjectAgentAvatarDataUrl(raw.avatarDataUrl) ? raw.avatarDataUrl : undefined,
roleName: typeof raw.roleName === 'string' && raw.roleName.trim()
? raw.roleName.trim()
: '项目伙伴',
name,
builtIn: raw.builtIn === true,
enabled: raw.enabled !== false,
model: typeof raw.model === 'string' && raw.model.trim() ? raw.model.trim() : null,
skillIds: normalizeProjectSkillIds(raw.skillIds),
responsibility: {
mission: typeof responsibility.mission === 'string' ? responsibility.mission.trim() : '',
owns: normalizeStringList(responsibility.owns),
boundaries: normalizeStringList(responsibility.boundaries),
collaborators: normalizeStringList(responsibility.collaborators),
principles: normalizeStringList(responsibility.principles),
},
prompt: typeof raw.prompt === 'string' ? raw.prompt.trim() : '',
archivedAt: typeof raw.archivedAt === 'string' && raw.archivedAt.trim()
? raw.archivedAt.trim()
: null,
pinned: raw.pinned === true,
createdAt: typeof raw.createdAt === 'string' && raw.createdAt.trim()
? raw.createdAt.trim()
: undefined,
updatedAt: typeof raw.updatedAt === 'string' && raw.updatedAt.trim()
? raw.updatedAt.trim()
: undefined,
};
}
export function normalizeLegacyProjectConfigV1(value: unknown): ProjectConfig {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new Error('Project config must be an object');
}
const raw = value as Partial<ProjectConfig>;
if (raw.schemaVersion !== 1) throw new Error('Unsupported project config schema');
const defaultModel = typeof raw.defaultModel === 'string' && raw.defaultModel.trim()
? raw.defaultModel.trim()
: null;
const agents = Array.isArray(raw.agents) ? raw.agents.map(normalizeAgent) : [];
if (agents.some((agent) => !agent)) throw new Error('Invalid project Agent configuration');
const normalizedAgents = agents
.filter((agent): agent is ProjectAgentConfig => Boolean(agent))
.map((agent) => agent.model || !defaultModel ? agent : { ...agent, model: defaultModel });
if (new Set(normalizedAgents.map((agent) => agent.id)).size !== normalizedAgents.length) {
throw new Error('Duplicate project Agent id');
}
const createdAt = typeof raw.createdAt === 'string' && raw.createdAt
? raw.createdAt
: new Date().toISOString();
const initialized = raw.initialized === true;
if (initialized && validateAgentNames(normalizedAgents).length > 0) {
throw new Error('Initialized project has invalid Agent names');
}
return {
schemaVersion: 1,
projectType: normalizeProjectType(raw.projectType),
initialized,
defaultModel,
agents: normalizedAgents,
knowledgeDirectory: 'knowledge',
createdAt,
updatedAt: typeof raw.updatedAt === 'string' && raw.updatedAt ? raw.updatedAt : createdAt,
};
}
function yamlString(value: string): string {
return JSON.stringify(value);
}
function buildProjectAgentPrompt(config: ProjectConfig, current: ProjectAgentConfig): string {
const peers = config.agents.filter((agent) => agent.id !== current.id);
const responsibility = current.responsibility.mission.trim()
? [
'## 你的职责',
current.responsibility.mission,
'',
'## 负责内容',
...current.responsibility.owns.map((item) => `- ${item}`),
'',
'## 工作边界',
...current.responsibility.boundaries.map((item) => `- ${item}`),
'',
'## 工作原则',
...current.responsibility.principles.map((item) => `- ${item}`),
'',
]
: [];
return [
`# ${current.name} · ${current.roleName}`,
'',
`你的名字是「${current.name}」,在与用户对话和介绍自己时使用这个名字。`,
`你在当前项目中的职能是「${current.roleName}」。`,
`你的稳定角色 ID 是 \`${current.id}\`。你只属于当前项目。`,
'',
current.prompt.trim(),
...responsibility,
'请尊重用户已有文件和未提交改动;遇到不确定的高影响决策时先说明影响。',
'',
'## 项目伙伴',
...(peers.length > 0
? peers.map((peer) => `- ${peer.name}${peer.roleName}${peer.id}`)
: ['- 暂无其他项目伙伴。']),
'',
'项目目录是当前项目的长期上下文。开始工作前读取与任务相关的文件,结束时把实际进展、假设、证据、风险和下一步写入项目文件。',
].join('\n');
}
function buildSelectedSkillGuidance(skillIds: string[]): string {
const guidance: string[] = [];
if (skillIds.includes('frontend-slides')) {
guidance.push([
'## 自动调用:项目演示',
'当用户要求项目汇报、结题展示、答辩、项目介绍或其他演示文稿时,主动调用 `frontend-slides`,把当前项目的真实内容整理成可播放的本地 HTML 幻灯片。项目接近完成且交付清单需要项目演示时,主动提醒用户并准备生成;不要生成 PPTX、不要部署到云端。',
].join('\n'));
}
if (skillIds.includes('grilling')) {
guidance.push([
'## 自动调用:方案质询',
'当任务涉及重要实现、设计、架构或其他未确认的高影响决策时,主动调用 `grilling`。在用户确认共享理解前,不要修改项目状态。',
].join('\n'));
}
if (skillIds.includes('planning-with-files')) {
guidance.push([
'## 自动调用:项目规划',
'当任务包含多个阶段、需要研究或可能跨会话继续时,主动调用 `planning-with-files`,并按该技能在当前项目根目录直接维护 `task_plan.md`、`findings.md` 和 `progress.md`;使用当前会话项目目录的绝对路径,不要写入 Skill 安装目录、进程工作目录、用户目录或 `.niancode/agent-planning/`。',
].join('\n'));
}
return guidance.join('\n\n');
}
function buildLegacyAgentMarkdown(config: ProjectConfig, agent: ProjectAgentConfig): string {
const skills = [
' "*": deny',
...normalizeProjectSkillIds(agent.skillIds)
.filter((skill) => skill !== '*')
.map((skill) => ` ${skill}: allow`),
].join('\n');
const shellPermission = agent.skillIds.includes('game-assets') ? ' bash: allow\n' : '';
const prompt = [
agent.prompt.trim() || buildProjectAgentPrompt(config, agent),
buildSelectedSkillGuidance(agent.skillIds),
].filter(Boolean).join('\n\n');
return `---
description: ${yamlString(agent.name)}
mode: all
${agent.model ? `model: ${yamlString(agent.model)}\n` : ''}permission:
${shellPermission} # Skills that produce local assets need the bundled CLI, never a remote bootstrap.
skill:
${skills}
---
${prompt}`;
}
export interface LegacyProjectAgentManifestEntry {
relativePath: string;
content: string;
}
export function buildLegacyProjectAgentManifest(
config: ProjectConfig,
): LegacyProjectAgentManifestEntry[] {
return config.agents
.map((agent) => ({
relativePath: path.posix.join('agent', `${agent.id}.md`),
content: buildLegacyAgentMarkdown(config, agent),
}))
.sort((left, right) => left.relativePath.localeCompare(right.relativePath));
}
function sanitizeLegacyProviderKey(value: string): string {
return value
.trim()
.replace(/[^A-Za-z0-9_-]+/g, '-')
.replace(/^-+|-+$/g, '')
|| 'provider';
}
function legacyProviderKey(account: Pick<ProviderAccount, 'id' | 'vendorId'>): string {
return account.vendorId === 'custom' || account.vendorId === 'ollama'
? sanitizeLegacyProviderKey(account.id)
: sanitizeLegacyProviderKey(account.vendorId);
}
function configuredLegacyModelIds(account: ProviderAccount, providerKey: string): Set<string> {
const ids = new Set<string>();
for (const value of [
account.model,
...(account.fallbackModels ?? []),
...(account.metadata?.customModels ?? []),
]) {
const normalized = value?.trim();
if (!normalized) continue;
ids.add(normalized.startsWith(`${providerKey}/`)
? normalized.slice(providerKey.length + 1)
: normalized);
}
return ids;
}
export function resolveLegacyProjectModel(
legacyModel: string,
accounts: ProviderAccount[],
): ProductModelRef | null {
const separator = legacyModel.indexOf('/');
if (separator <= 0 || separator >= legacyModel.length - 1) return null;
const providerKey = legacyModel.slice(0, separator);
const modelId = legacyModel.slice(separator + 1);
const matches = accounts.filter((account) => (
legacyProviderKey(account) === providerKey
&& configuredLegacyModelIds(account, providerKey).has(modelId)
));
if (matches.length !== 1) return null;
return {
accountId: matches[0].id,
modelId,
thinkingLevel: 'off',
};
}

View File

@@ -2,16 +2,15 @@ import {
copyFile,
mkdir,
readFile,
readdir,
rm,
unlink,
} from 'node:fs/promises';
import path from 'node:path';
import type { ProductModelRef } from '../coding-runtime/contracts';
import {
buildProjectAgentManifest,
normalizeProjectConfig,
} from '../opencode/project-config';
buildLegacyProjectAgentManifest,
normalizeLegacyProjectConfigV1,
} from './legacy-v1';
import type { ProjectAgentConfig, ProjectConfig } from '../../shared/project-config';
import {
atomicWriteJson,
@@ -90,32 +89,24 @@ async function snapshotLegacyAgentFiles(
copy: typeof copyFile,
): Promise<AgentFileSnapshot[]> {
const agentDirectory = path.join(projectPath, '.opencode', 'agent');
let entries;
try {
entries = await readdir(agentDirectory, { withFileTypes: true });
} catch (error) {
if (isMissing(error)) return [];
throw error;
}
const expected = new Map(buildProjectAgentManifest(config).entries.map((entry) => [
const expected = new Map(buildLegacyProjectAgentManifest(config).map((entry) => [
path.basename(entry.relativePath),
entry.content,
]));
const snapshots: AgentFileSnapshot[] = [];
for (const entry of entries) {
if (!entry.isFile()) continue;
const filePath = path.join(agentDirectory, entry.name);
const content = await readFile(filePath, 'utf8');
const generated = expected.get(entry.name) === content;
for (const [fileName, expectedContent] of expected) {
const filePath = path.join(agentDirectory, fileName);
const content = await readOptionalText(filePath);
if (content === null) continue;
const generated = expectedContent === content;
if (generated) {
snapshots.push({ fileName: entry.name, filePath, content, generated });
snapshots.push({ fileName, filePath, content, generated });
continue;
}
const backupPath = path.join(backupDirectory, '.opencode', 'agent', entry.name);
const backupPath = path.join(backupDirectory, '.opencode', 'agent', fileName);
await mkdir(path.dirname(backupPath), { recursive: true });
await copy(filePath, backupPath);
snapshots.push({ fileName: entry.name, filePath, content, generated, backupPath });
snapshots.push({ fileName, filePath, content, generated, backupPath });
}
return snapshots;
}
@@ -239,7 +230,7 @@ export async function migrateCodingProjectToV2(
config: normalizeCodingProjectConfigV2(rawProject),
};
}
const normalizedLegacyConfig = normalizeProjectConfig(rawProject);
const normalizedLegacyConfig = normalizeLegacyProjectConfigV1(rawProject);
const legacyConfig = preserveLegacyAgentFields(rawProject, normalizedLegacyConfig);
const conversationsSource = await readOptionalText(conversationsPath);
const migrationTimestamp = dependencies.now?.() ?? new Date().toISOString();
@@ -247,7 +238,7 @@ export async function migrateCodingProjectToV2(
projectPath,
'.niancode',
'migration-backups',
`opencode-cutover-${timestampKey(migrationTimestamp)}`,
`pi-cutover-${timestampKey(migrationTimestamp)}`,
);
const copy = dependencies.copyFile ?? copyFile;
const writeJson = dependencies.writeJson ?? atomicWriteJson;

View File

@@ -6,11 +6,16 @@ import {
type CodingConversationV2,
} from './conversation-store';
import {
acknowledgeLegacyConversationNotice,
normalizeCodingProjectConfigV2,
readCodingProjectConfigV2,
writeCodingProjectConfigV2,
type CodingProjectConfigV2,
} from './project-config';
import {
migrateCodingProjectToV2,
type CodingProjectMigrationDependencies,
} from './migration';
import {
createLocalCodingProject,
type CodingProject,
@@ -58,6 +63,7 @@ export interface CodingProjectServiceOptions {
onProjectDeactivated?(project: CodingProject): Promise<void> | void;
createConversationStore?: typeof createCodingConversationStore;
writeConfig?: typeof writeCodingProjectConfigV2;
migration?: CodingProjectMigrationDependencies;
}
function requiredAbsolutePath(value: string | undefined, label: string): string {
@@ -97,6 +103,7 @@ export class CodingProjectService {
ReturnType<typeof createCodingConversationStore>
>();
private activeTransitionTail = Promise.resolve();
private readonly migrationFlights = new Map<string, Promise<CodingProjectConfigV2>>();
constructor(
private readonly store: CodingProjectStore,
@@ -202,8 +209,8 @@ export class CodingProjectService {
async setActiveProject(projectId: string): Promise<CodingProject> {
const project = await this.getProject(projectId);
const config = await readCodingProjectConfigV2(project.path);
if (config.status !== 'valid') {
const config = await this.readCurrentConfig(project.path);
if (!config) {
throw new CodingProjectServiceError(
409,
'CODING_PROJECT_CONFIG_INVALID',
@@ -222,8 +229,8 @@ export class CodingProjectService {
async getConfig(projectId?: string): Promise<CodingProjectConfigSnapshot> {
const project = projectId ? await this.getProject(projectId) : await this.requireActiveProject();
const result = await readCodingProjectConfigV2(project.path);
if (result.status !== 'valid') {
const config = await this.readCurrentConfig(project.path);
if (!config) {
throw new CodingProjectServiceError(
409,
'CODING_PROJECT_CONFIG_INVALID',
@@ -232,7 +239,7 @@ export class CodingProjectService {
}
return {
project,
config: result.config,
config,
knowledgeFiles: await this.listKnowledgeFiles(project.path),
};
}
@@ -297,6 +304,16 @@ export class CodingProjectService {
return await this.listKnowledgeFiles(project.path);
}
async acknowledgeLegacyConversationNotice(projectId: string): Promise<CodingProjectConfigSnapshot> {
const project = await this.getProject(projectId);
const config = await acknowledgeLegacyConversationNotice(project.path);
return {
project,
config,
knowledgeFiles: await this.listKnowledgeFiles(project.path),
};
}
conversationStore(projectPath: string): ReturnType<typeof createCodingConversationStore> {
const existing = this.conversationStores.get(projectPath);
if (existing) return existing;
@@ -331,6 +348,23 @@ export class CodingProjectService {
}
}
private async readCurrentConfig(projectPath: string): Promise<CodingProjectConfigV2 | null> {
const current = await readCodingProjectConfigV2(projectPath);
if (current.status === 'valid') return current.config;
if (current.status !== 'invalid' || !this.options.migration) return null;
const existing = this.migrationFlights.get(projectPath);
if (existing) return await existing;
const flight = migrateCodingProjectToV2(projectPath, this.options.migration)
.then((result) => result.config)
.finally(() => {
if (this.migrationFlights.get(projectPath) === flight) {
this.migrationFlights.delete(projectPath);
}
});
this.migrationFlights.set(projectPath, flight);
return await flight;
}
private transitionActiveProject<T>(operation: () => Promise<{
project: CodingProject;
value: T;

View File

@@ -16,6 +16,12 @@ export interface CodingProjectStoreData {
activeProjectId: string | null;
}
export type CodingProjectStoreChange =
| { type: 'upsert'; project: CodingProject }
| { type: 'remove'; projectId: string };
export type CodingProjectStoreListener = (change: CodingProjectStoreChange) => void;
export interface CodingProjectStorage {
read(): Promise<CodingProjectStoreData | undefined>;
write(data: CodingProjectStoreData): Promise<void>;
@@ -67,8 +73,7 @@ export function createCodingProjectStorageFromStore(
export async function createElectronCodingProjectStorage(): Promise<CodingProjectStorage> {
const Store = (await import('electron-store')).default;
const store = new Store<{ projects?: CodingProjectStoreData }>({
// Preserve the installed-user storage identity while replacing the
// OpenCode-owned service and types with the product-owned store.
// Legacy installed-user storage identity. Renaming it would orphan projects.
name: 'opencode-projects',
});
return createCodingProjectStorageFromStore(store, 'projects');
@@ -88,6 +93,17 @@ export function createCodingProjectStore(
const createId = options.createId ?? randomUUID;
const now = options.now ?? (() => new Date().toISOString());
let mutationTail = Promise.resolve();
const listeners = new Set<CodingProjectStoreListener>();
function emit(change: CodingProjectStoreChange): void {
for (const listener of listeners) {
try {
listener(change);
} catch {
// Persistence must not fail because an observer failed.
}
}
}
async function readData(): Promise<CodingProjectStoreData> {
return (await storage.read()) ?? emptyStoreData();
@@ -124,6 +140,7 @@ export function createCodingProjectStore(
data.projects[project.id] = project;
if (activate) data.activeProjectId = project.id;
await storage.write(data);
emit({ type: 'upsert', project });
return project;
});
}
@@ -164,8 +181,14 @@ export function createCodingProjectStore(
delete data.projects[projectId];
if (data.activeProjectId === projectId) data.activeProjectId = null;
await storage.write(data);
emit({ type: 'remove', projectId });
});
},
subscribe(listener: CodingProjectStoreListener): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
},
};
}

View File

@@ -1,4 +1,4 @@
import { readFile } from 'node:fs/promises';
import { readFile, readdir } from 'node:fs/promises';
import path from 'node:path';
import {
BUNDLED_CODING_SKILL_IDS,
@@ -50,18 +50,40 @@ function selectedSkillIds(value: readonly string[]): Set<BundledCodingSkillId> {
return selected;
}
async function listSkillEntries(
directory: string,
relative = '',
): Promise<Array<{ path: string; type: 'file' | 'directory' }>> {
const entries = await readdir(path.join(directory, relative), { withFileTypes: true });
const result: Array<{ path: string; type: 'file' | 'directory' }> = [];
for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
const entryPath = relative ? path.posix.join(relative, entry.name) : entry.name;
if (entry.isDirectory()) {
result.push({ path: entryPath, type: 'directory' });
result.push(...await listSkillEntries(directory, entryPath));
} else if (entry.isFile()) {
result.push({ path: entryPath, type: 'file' });
}
}
return result;
}
export async function listProductCodingSkills(
bundledSkillsDir: string,
selectedIds: readonly string[] = [],
): Promise<ProductCodingSkill[]> {
const selected = selectedSkillIds(selectedIds);
return await Promise.all(BUNDLED_CODING_SKILL_IDS.map(async (id) => {
const content = await readFile(path.join(bundledSkillsDir, id, 'SKILL.md'), 'utf8');
const location = path.join(bundledSkillsDir, id);
const content = await readFile(path.join(location, 'SKILL.md'), 'utf8');
return {
id,
name: frontmatterScalar(content, 'name') ?? id,
description: frontmatterScalar(content, 'description') ?? '',
selected: selected.has(id),
location,
content,
entries: await listSkillEntries(location),
};
}));
}