feat(daemon, web): implement plugin folder installation and enhance atom worker registry

- Added a new API endpoint for installing plugins from specified folder paths, improving the plugin management experience.
- Introduced functions for normalizing and validating project plugin folder paths, ensuring robust error handling.
- Implemented a registry for built-in atom workers, allowing for dynamic signal aggregation during pipeline execution.
- Enhanced the `runStageWithRegistry` function to support multiple atom workers, merging their outputs with pessimistic logic.
- Updated the UI components to display plugin folder candidates and facilitate user interactions for plugin installation.
- Added tests for the new atom worker registry and plugin folder installation features, ensuring reliability and correctness.

This update significantly enhances the plugin installation process and the overall functionality of the atom worker system, providing users with better tools for managing plugins and their interactions.
This commit is contained in:
pftom 2026-05-12 21:38:45 +08:00
parent 61a68a34f8
commit 6f818d971d
30 changed files with 1702 additions and 68 deletions

View file

@ -0,0 +1,79 @@
// Plan §3.D — built-in atom workers.
//
// Registered on first use into the worker registry. Every atom in
// FIRST_PARTY_ATOMS gets at least a permissive worker so the
// registry-driven pipeline runner stays at parity with the v1 stub
// for atoms whose real work happens entirely inside the agent CLI
// (file-write, todo-write, media-image, …) — the daemon has no
// independent ground truth to observe there and shipping a real
// watcher would force the agent into a fixed protocol we explicitly
// kept out of scope.
//
// One atom does have a daemon-observable signal today:
// `critique-theater`. The worker walks the run's devloop audit log
// (`run_devloop_iterations.critique_summary`) and surfaces the
// most recent numeric score it finds. Picking "latest" rather than
// "lowest" matches real critique-loop semantics: the agent revises
// based on prior critique, so each new score reflects the current
// quality bar, not the worst earlier attempt.
import { FIRST_PARTY_ATOMS } from '../atoms.js';
import {
registerAtomWorker,
type AtomOutcome,
type AtomWorkerContext,
} from './registry.js';
let installed = false;
export function registerBuiltInAtomWorkers(): void {
if (installed) return;
for (const atom of FIRST_PARTY_ATOMS) {
if (atom.id === 'critique-theater') {
registerAtomWorker({
id: atom.id,
describe: 'reads run_devloop_iterations.critique_summary for real critique scores',
run: critiqueTheaterWorker,
});
continue;
}
registerAtomWorker({
id: atom.id,
describe: 'permissive default (daemon has no independent ground truth for this atom)',
run: () => ({ signals: {} }),
});
}
installed = true;
}
export function resetBuiltInAtomWorkersForTests(): void {
installed = false;
}
function critiqueTheaterWorker(ctx: AtomWorkerContext): AtomOutcome {
type Row = { iteration: number; critique_summary: string | null };
const rows = ctx.db
.prepare(
'SELECT iteration, critique_summary FROM run_devloop_iterations WHERE run_id = ? AND stage_id = ? ORDER BY iteration DESC',
)
.all(ctx.runId, ctx.stage.id) as Row[];
for (const row of rows) {
const score = parseCritiqueScore(row.critique_summary);
if (score === null) continue;
return {
signals: { 'critique.score': score },
note: `latest critique score=${score} from iteration ${row.iteration}`,
};
}
return { signals: {} };
}
// Matches `score=4`, `score: 4.5`, `Critique score 4/5`, etc.
function parseCritiqueScore(summary: string | null): number | null {
if (!summary) return null;
const match = summary.match(/score\s*[:=]?\s*(\d+(?:\.\d+)?)/i);
if (!match) return null;
const parsed = Number(match[1]);
if (!Number.isFinite(parsed)) return null;
return parsed;
}

View file

@ -0,0 +1,144 @@
// Plan §3.D — atom worker registry.
//
// Stage D of plugin-driven-flow-plan replaces the canned stub stage
// runner inside `firePipelineForRun` with a per-atom worker model.
// Each atom can register a `run(ctx)` that observes the run's DB
// state and returns real `UntilSignals`; atoms with no registered
// worker fall through silently so the stage runner keeps converging
// at parity with the v1 stub for unobserved pipelines.
//
// The registry stays intentionally minimal:
// - module-level `Map<atomId, AtomWorker>` so registration is a
// simple side-effect (built-ins.ts registers FIRST_PARTY_ATOMS
// on first use)
// - `runStageWithRegistry(ctx)` walks `stage.atoms`, asks each
// registered worker for its signals, then pessimistically
// merges them (lowest number / false-wins boolean) so a single
// atom returning `critique.score: 2` overrides the optimistic
// defaults
// - permissive defaults (`critique.score: 4`, `preview.ok: true`,
// `user.confirmed: true`) keep the happy path converging in one
// iteration when no atom contradicts them — matches v1 stub
// behaviour for backwards compatibility.
import type Database from 'better-sqlite3';
import type {
AppliedPluginSnapshot,
PipelineStage,
} from '@open-design/contracts';
import type { UntilSignals } from '../until.js';
type SqliteDb = Database.Database;
export interface AtomWorkerContext {
db: SqliteDb;
runId: string;
projectId: string;
conversationId: string | null;
stage: PipelineStage;
iteration: number;
snapshot: AppliedPluginSnapshot;
}
export interface AtomOutcome {
signals?: UntilSignals;
// Optional free-form note appended to the stage's
// `run_devloop_iterations.critique_summary` audit column.
note?: string;
}
export interface AtomWorker {
id: string;
describe?: string;
run: (ctx: AtomWorkerContext) => Promise<AtomOutcome> | AtomOutcome;
}
const REGISTRY = new Map<string, AtomWorker>();
export function registerAtomWorker(worker: AtomWorker): void {
REGISTRY.set(worker.id, worker);
}
export function unregisterAtomWorker(id: string): void {
REGISTRY.delete(id);
}
export function clearAtomWorkers(): void {
REGISTRY.clear();
}
export function getAtomWorker(id: string): AtomWorker | undefined {
return REGISTRY.get(id);
}
export function listRegisteredAtomIds(): string[] {
return Array.from(REGISTRY.keys()).sort();
}
// Permissive defaults mirror the v1 stub's canned signals so the
// registry runner stays at parity for unobserved atoms — swapping
// from stub → registry never regresses happy-path convergence.
// Real worker observations REPLACE these defaults wholesale (rather
// than min-merging) so a real score of 5 never gets clipped to the
// default 4; cross-worker conflicts inside a single stage still
// pessimistically merge (false-wins / lowest-number-wins).
export const PERMISSIVE_DEFAULT_SIGNALS: Readonly<UntilSignals> = Object.freeze({
'critique.score': 4,
'preview.ok': true,
'user.confirmed': true,
});
export interface StageRegistryOutcome {
signals: UntilSignals;
critiqueSummary: string | null;
notes: string[];
observedAtoms: string[];
}
// Walk every atom in the stage, invoke its registered worker (if
// any), then layer the resulting real-observation map over the
// permissive defaults. Worker failures are captured as notes and
// never crash the stage — the devloop scheduler keeps its
// iteration cap as the safety net.
export async function runStageWithRegistry(
ctx: AtomWorkerContext,
): Promise<StageRegistryOutcome> {
const real = new Map<keyof UntilSignals, unknown>();
const notes: string[] = [];
const observed: string[] = [];
for (const atomId of ctx.stage.atoms ?? []) {
const worker = getAtomWorker(atomId);
if (!worker) continue;
observed.push(atomId);
try {
const out = await Promise.resolve(worker.run(ctx));
for (const [k, v] of Object.entries(out.signals ?? {})) {
const key = k as keyof UntilSignals;
const prev = real.get(key);
real.set(key, prev === undefined ? v : mergePessimistic(prev, v));
}
if (out.note) notes.push(`[${worker.id}] ${out.note}`);
} catch (err) {
notes.push(`[${worker.id}] worker error: ${(err as Error).message ?? String(err)}`);
}
}
const accumulated: UntilSignals = { ...PERMISSIVE_DEFAULT_SIGNALS };
for (const [key, value] of real) {
accumulated[key] = value as never;
}
return {
signals: accumulated,
critiqueSummary: notes.length > 0 ? notes.join('\n') : null,
notes,
observedAtoms: observed,
};
}
// Pessimistic merge between multiple workers contributing to the
// same signal key. Cross-worker false-wins / lowest-number-wins
// so a single failing gate still surfaces as a failed convergence.
function mergePessimistic(prev: unknown, next: unknown): unknown {
if (typeof prev === 'boolean' && typeof next === 'boolean') return prev && next;
if (typeof prev === 'number' && typeof next === 'number') return Math.min(prev, next);
return next;
}

View file

@ -72,6 +72,7 @@ export {
type PurgePluginEventBufferResult,
} from './events.js';
export * from './atoms/build-test.js';
export * from './atoms/built-ins.js';
export * from './atoms/code-import.js';
export * from './atoms/design-extract.js';
export * from './atoms/diff-review.js';
@ -79,6 +80,7 @@ export * from './atoms/diff-review-genui-bridge.js';
export * from './atoms/figma-extract.js';
export * from './atoms/handoff.js';
export * from './atoms/patch-edit.js';
export * from './atoms/registry.js';
export * from './atoms/rewrite-plan.js';
export * from './atoms/token-map.js';
export * from './bundled.js';

View file

@ -56,9 +56,11 @@ import {
MissingInputError,
pluginPromptBlock,
pruneExpiredSnapshots,
registerBuiltInAtomWorkers,
registerBundledPlugins,
resolvePluginSnapshot,
runPipelineForRun,
runStageWithRegistry,
startSnapshotGc,
uninstallPlugin,
} from './plugins/index.js';
@ -1090,6 +1092,34 @@ function sendApiError(res, status, code, message, init = {}) {
.json(createCompatApiErrorResponse(code, message, init));
}
function normalizeProjectPluginFolderPath(input) {
const value = String(input ?? '').replace(/\\/g, '/').trim();
if (!value || value.includes('\0') || value.startsWith('/') || /^[A-Za-z]:\//.test(value)) {
throw new Error('plugin folder path must be a relative project path');
}
const parts = value.split('/').filter(Boolean);
if (parts.length === 0 || parts.some((part) => part === '.' || part === '..')) {
throw new Error('plugin folder path must not contain traversal segments');
}
return parts.join('/');
}
async function resolveProjectChildDirectory(projectRoot, relativePath) {
const rootReal = await fs.promises.realpath(projectRoot);
const candidate = path.resolve(projectRoot, relativePath);
const real = await fs.promises.realpath(candidate);
if (!real.startsWith(rootReal + path.sep) && real !== rootReal) {
throw new Error('plugin folder path escapes project dir');
}
const st = await fs.promises.stat(real);
if (!st.isDirectory()) {
const err = new Error('plugin folder path is not a directory');
err.code = 'ENOTDIR';
throw err;
}
return real;
}
const CLOUDFLARE_PAGES_PROJECT_METADATA_KEY = 'cloudflarePagesProjectName';
function cloudflarePagesDeploymentMetadata(projectName) {
@ -5749,6 +5779,47 @@ export async function startServer({
}
});
app.post('/api/projects/:id/plugins/install-folder', async (req, res) => {
try {
const project = getProject(db, req.params.id);
if (!project) {
sendApiError(res, 404, 'PROJECT_NOT_FOUND', 'project not found');
return;
}
const body = req.body && typeof req.body === 'object' ? req.body : {};
const relativePath = normalizeProjectPluginFolderPath(body.path);
const projectRoot = resolveProjectDir(PROJECTS_DIR, req.params.id, project.metadata);
const folder = await resolveProjectChildDirectory(projectRoot, relativePath);
const warnings = [];
const log = [];
let plugin = null;
let message = 'Install finished.';
for await (const ev of installPlugin(db, { source: folder })) {
if (ev.message) log.push(ev.message);
if (Array.isArray(ev.warnings)) warnings.splice(0, warnings.length, ...ev.warnings);
if (ev.kind === 'success') {
plugin = ev.plugin;
message = `Installed ${ev.plugin.title}.`;
break;
}
if (ev.kind === 'error') {
message = ev.message;
break;
}
}
res.status(plugin ? 200 : 400).json({ ok: Boolean(plugin), plugin, warnings, message, log });
} catch (err) {
const code = err && err.code;
const status = code === 'ENOENT' || code === 'ENOTDIR' ? 404 : 400;
sendApiError(
res,
status,
status === 404 ? 'PLUGIN_FOLDER_NOT_FOUND' : 'BAD_REQUEST',
String(err?.message || err),
);
}
});
app.get('/api/projects/:id/search', async (req, res) => {
try {
const query = String(req.query.q ?? '');
@ -6620,13 +6691,14 @@ export async function startServer({
return { prompt, activeSkillDir, critiqueShouldRun };
};
// Plan §3.I1 / spec §10.1: fire the pipeline schedule on a run's
// SSE stream. Synchronous first emit (the first
// Plan §3.I1 / §3.D / spec §10.1: fire the pipeline schedule on a
// run's SSE stream. Synchronous first emit (the first
// pipeline_stage_started event lands before the agent process
// starts) + async tail. Stub stage runner converges any non-loop
// stage in one iteration; loop stages stop at the
// OD_MAX_DEVLOOP_ITERATIONS ceiling. Errors are swallowed (logged)
// so a bad pipeline never blocks the agent run.
// starts) + async tail. Stage D wires the atom-worker registry as
// the default stage runner; set OD_PIPELINE_RUNNER=stub to fall
// back to the canned v1 stub for diagnostic bisection or replay
// of pre-Stage-D runs. Errors are swallowed (logged) so a bad
// pipeline never blocks the agent run.
const firePipelineForRun = (args) => {
const { run, snapshot, runs, db: dbHandle } = args;
if (!snapshot?.pipeline?.stages?.length) return;
@ -6637,20 +6709,43 @@ export async function startServer({
const emitGenui = (evt) => {
try { runs.emit(run, evt.kind, evt); } catch {/* ignore */}
};
// Stub stage runner: synchronously returns a converged signal so
// the scheduler advances immediately. Phase 4's atom migration
// swaps this for a real per-stage worker that drives the agent.
const runStage = ({ iteration }) => ({
signals: {
'critique.score': iteration >= 0 ? 4 : 0,
'preview.ok': true,
'user.confirmed': true,
},
});
const projectIdForRun = run.projectId
?? snapshot.resolvedContext?.items?.[0]?.id
?? 'project-unknown';
const runnerMode = process.env.OD_PIPELINE_RUNNER === 'stub'
? 'stub'
: 'registry';
let runStage;
if (runnerMode === 'stub') {
runStage = ({ iteration }) => ({
signals: {
'critique.score': iteration >= 0 ? 4 : 0,
'preview.ok': true,
'user.confirmed': true,
},
});
} else {
registerBuiltInAtomWorkers();
runStage = async ({ stage, iteration, snapshot: stageSnapshot }) => {
const outcome = await runStageWithRegistry({
db: dbHandle,
runId: run.id,
projectId: projectIdForRun,
conversationId: run.conversationId ?? null,
stage,
iteration,
snapshot: stageSnapshot,
});
return {
signals: outcome.signals,
critiqueSummary: outcome.critiqueSummary,
};
};
}
void runPipelineForRun({
db: dbHandle,
runId: run.id,
projectId: run.projectId ?? snapshot.resolvedContext?.items?.[0]?.id ?? 'project-unknown',
projectId: projectIdForRun,
conversationId: run.conversationId ?? null,
snapshot,
pipeline: snapshot.pipeline,

View file

@ -0,0 +1,248 @@
// Stage D of plugin-driven-flow-plan — atom worker registry tests.
//
// Covers:
// - Registration / lookup / clearing surface.
// - `runStageWithRegistry` aggregates signals across multiple atoms
// using pessimistic merge (lowest number / false-wins boolean).
// - Permissive defaults preserve happy-path convergence when no
// atom registers a real worker.
// - Worker errors are captured as notes instead of crashing the
// stage so a bad atom never blocks the run.
// - The built-in `critique-theater` worker reads
// `run_devloop_iterations` and surfaces the lowest numeric score
// it can find — and falls through silently when no score is
// present yet (e.g. the first iteration before the agent has
// written a critique).
// - Built-in registration is idempotent and the test-only reset
// hook restores the install flag for the next case.
//
// The registry is module-scoped so each `beforeEach` calls
// `clearAtomWorkers()` + `resetBuiltInAtomWorkersForTests()` to
// guarantee independence between cases.
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { mkdtemp, rm } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import Database from 'better-sqlite3';
import type { AppliedPluginSnapshot, PipelineStage } from '@open-design/contracts';
import { migratePlugins } from '../src/plugins/persistence.js';
import {
PERMISSIVE_DEFAULT_SIGNALS,
clearAtomWorkers,
getAtomWorker,
listRegisteredAtomIds,
registerAtomWorker,
runStageWithRegistry,
type AtomWorkerContext,
} from '../src/plugins/atoms/registry.js';
import {
registerBuiltInAtomWorkers,
resetBuiltInAtomWorkersForTests,
} from '../src/plugins/atoms/built-ins.js';
let db: Database.Database;
let tmpDir: string;
beforeEach(async () => {
tmpDir = await mkdtemp(path.join(os.tmpdir(), 'od-atom-reg-'));
db = new Database(path.join(tmpDir, 'test.sqlite'));
db.exec(`
CREATE TABLE projects (id TEXT PRIMARY KEY, name TEXT);
CREATE TABLE conversations (id TEXT PRIMARY KEY, project_id TEXT, title TEXT);
`);
migratePlugins(db);
clearAtomWorkers();
resetBuiltInAtomWorkersForTests();
});
afterEach(async () => {
db.close();
await rm(tmpDir, { recursive: true, force: true });
});
function fakeSnapshot(): AppliedPluginSnapshot {
return {
snapshotId: 'snap-1',
projectId: 'project-1',
conversationId: 'conv-A',
runId: 'run-1',
pluginId: 'sample-plugin',
pluginVersion: '1.0.0',
pluginTitle: 'Sample',
pluginDescription: '',
manifestSourceDigest: 'digest-1',
taskKind: 'new-generation',
inputs: {},
resolvedContext: { items: [] },
capabilitiesGranted: ['prompt:inject'],
capabilitiesRequired: ['prompt:inject'],
assetsStaged: [],
connectorsRequired: [],
connectorsResolved: [],
mcpServers: [],
query: '',
createdAt: Date.now(),
} as unknown as AppliedPluginSnapshot;
}
function ctxFor(stage: PipelineStage, iteration = 0): AtomWorkerContext {
return {
db,
runId: 'run-1',
projectId: 'project-1',
conversationId: 'conv-A',
stage,
iteration,
snapshot: fakeSnapshot(),
};
}
describe('atom registry: registration + lookup', () => {
it('returns undefined for unregistered ids and lists registered ones alphabetically', () => {
expect(getAtomWorker('nope')).toBeUndefined();
registerAtomWorker({ id: 'zebra', run: () => ({}) });
registerAtomWorker({ id: 'alpha', run: () => ({}) });
expect(listRegisteredAtomIds()).toEqual(['alpha', 'zebra']);
});
it('clears the registry on demand', () => {
registerAtomWorker({ id: 'temp', run: () => ({}) });
expect(listRegisteredAtomIds()).toContain('temp');
clearAtomWorkers();
expect(listRegisteredAtomIds()).toEqual([]);
});
});
describe('runStageWithRegistry: signal aggregation', () => {
it('falls through to permissive defaults when no atom has a registered worker', async () => {
const stage: PipelineStage = { id: 's', atoms: ['unknown-1', 'unknown-2'] };
const out = await runStageWithRegistry(ctxFor(stage));
expect(out.signals).toEqual(PERMISSIVE_DEFAULT_SIGNALS);
expect(out.observedAtoms).toEqual([]);
expect(out.notes).toEqual([]);
expect(out.critiqueSummary).toBeNull();
});
it('pessimistically merges numeric signals (lowest wins) across multiple atoms', async () => {
registerAtomWorker({
id: 'judge-low',
run: () => ({ signals: { 'critique.score': 2 } }),
});
registerAtomWorker({
id: 'judge-high',
run: () => ({ signals: { 'critique.score': 5 } }),
});
const stage: PipelineStage = { id: 's', atoms: ['judge-low', 'judge-high'] };
const out = await runStageWithRegistry(ctxFor(stage));
expect(out.signals['critique.score']).toBe(2);
expect(out.observedAtoms).toEqual(['judge-low', 'judge-high']);
});
it('pessimistically merges boolean signals (false wins) across atoms', async () => {
registerAtomWorker({
id: 'gate-pass',
run: () => ({ signals: { 'preview.ok': true } }),
});
registerAtomWorker({
id: 'gate-fail',
run: () => ({ signals: { 'preview.ok': false } }),
});
const stage: PipelineStage = { id: 's', atoms: ['gate-pass', 'gate-fail'] };
const out = await runStageWithRegistry(ctxFor(stage));
expect(out.signals['preview.ok']).toBe(false);
});
it('captures worker errors as notes without crashing the stage', async () => {
registerAtomWorker({
id: 'boom',
run: () => {
throw new Error('boom');
},
});
registerAtomWorker({
id: 'ok',
run: () => ({ signals: { 'critique.score': 4 }, note: 'looks fine' }),
});
const stage: PipelineStage = { id: 's', atoms: ['boom', 'ok'] };
const out = await runStageWithRegistry(ctxFor(stage));
expect(out.signals['critique.score']).toBe(4);
expect(out.notes.some((n) => n.includes('worker error: boom'))).toBe(true);
expect(out.notes.some((n) => n.includes('looks fine'))).toBe(true);
expect(out.critiqueSummary).not.toBeNull();
});
it('awaits async workers and applies their signals to the merge', async () => {
registerAtomWorker({
id: 'slow-judge',
run: async () => {
await new Promise((resolve) => setTimeout(resolve, 5));
return { signals: { 'critique.score': 1 } };
},
});
const stage: PipelineStage = { id: 's', atoms: ['slow-judge'] };
const out = await runStageWithRegistry(ctxFor(stage));
expect(out.signals['critique.score']).toBe(1);
});
});
describe('built-in critique-theater worker', () => {
beforeEach(() => {
registerBuiltInAtomWorkers();
});
it('returns permissive (no score) when no devloop row exists yet', async () => {
const stage: PipelineStage = { id: 'critique', atoms: ['critique-theater'] };
const out = await runStageWithRegistry(ctxFor(stage));
expect(out.signals).toEqual(PERMISSIVE_DEFAULT_SIGNALS);
expect(out.observedAtoms).toEqual(['critique-theater']);
expect(out.critiqueSummary).toBeNull();
});
it('parses a numeric score=N from critique_summary and surfaces the latest iteration that has one', async () => {
insertIteration('critique', 1, 'critique panel: score=2');
insertIteration('critique', 2, 'no parseable score here');
insertIteration('critique', 3, 'score=4');
const stage: PipelineStage = { id: 'critique', atoms: ['critique-theater'] };
const out = await runStageWithRegistry(ctxFor(stage));
expect(out.signals['critique.score']).toBe(4);
expect(out.critiqueSummary).toContain('latest critique score=4 from iteration 3');
});
it('falls back to the most recent parseable iteration when the newest row has no score', async () => {
insertIteration('critique', 1, 'score=3');
insertIteration('critique', 2, 'unrelated notes only');
const stage: PipelineStage = { id: 'critique', atoms: ['critique-theater'] };
const out = await runStageWithRegistry(ctxFor(stage));
expect(out.signals['critique.score']).toBe(3);
});
it('only looks at iterations for the active stage, ignoring siblings', async () => {
insertIteration('discovery', 1, 'score=1');
insertIteration('critique', 1, 'score=4');
const stage: PipelineStage = { id: 'critique', atoms: ['critique-theater'] };
const out = await runStageWithRegistry(ctxFor(stage));
expect(out.signals['critique.score']).toBe(4);
});
});
describe('registerBuiltInAtomWorkers: idempotency', () => {
it('registers every FIRST_PARTY_ATOM exactly once even on repeat calls', () => {
registerBuiltInAtomWorkers();
const first = listRegisteredAtomIds();
registerBuiltInAtomWorkers();
const second = listRegisteredAtomIds();
expect(second).toEqual(first);
expect(first).toContain('critique-theater');
expect(first).toContain('file-write');
expect(first).toContain('media-image');
});
});
function insertIteration(stageId: string, iteration: number, summary: string): void {
db.prepare(
`INSERT INTO run_devloop_iterations
(id, run_id, stage_id, iteration, artifact_diff_summary, critique_summary, tokens_used, ended_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
).run(`iter-${stageId}-${iteration}`, 'run-1', stageId, iteration, null, summary, null, Date.now());
}

View file

@ -31,6 +31,7 @@ const CANONICAL = new Map<string, { taskKind: string; pipelineStages: string[] }
// project kinds (image / video / audio) onto these plugins.
const SIBLINGS = new Map<string, { taskKind: string }>([
['od-media-generation', { taskKind: 'new-generation' }],
['od-plugin-authoring', { taskKind: 'new-generation' }],
]);
describe('plugins/_official/scenarios roster', () => {

View file

@ -28,6 +28,15 @@ import { runPipelineForRun } from '../src/plugins/pipeline-runner.js';
import { listIterationsForRun } from '../src/plugins/pipeline.js';
import { respondSurface } from '../src/genui/registry.js';
import { findPendingByRunAndSurfaceId } from '../src/genui/store.js';
import {
clearAtomWorkers,
registerAtomWorker,
runStageWithRegistry,
} from '../src/plugins/atoms/registry.js';
import {
registerBuiltInAtomWorkers,
resetBuiltInAtomWorkersForTests,
} from '../src/plugins/atoms/built-ins.js';
let db: Database.Database;
let tmpDir: string;
@ -151,6 +160,104 @@ describe('pipeline-runner: stage events + devloop persistence', () => {
});
});
describe('pipeline-runner: Stage D registry runner integration', () => {
beforeEach(() => {
clearAtomWorkers();
resetBuiltInAtomWorkersForTests();
registerBuiltInAtomWorkers();
});
// Drives the same pipeline the daemon uses (critique-theater
// atom + critique.score until), but with the registry runner as
// the stage worker. Each iteration emulates an agent that writes
// its critique back into run_devloop_iterations, then yields. The
// built-in critique-theater worker reads the latest row on the
// next iteration and surfaces it as `critique.score`. Convergence
// happens once the agent's latest score crosses the threshold.
it('drives convergence through a registered atom worker that overrides permissive defaults', async () => {
// Replace the built-in critique-theater with a test-only worker
// so the integration test is independent of the audit-log read
// path. Threshold sits above the permissive default (4) so the
// worker's signal alone determines convergence.
registerAtomWorker({
id: 'critique-theater',
run: ({ iteration }) => ({
// Ramps from 2 → 5 across iterations so the loop iterates
// a known number of times before converging.
signals: { 'critique.score': iteration >= 2 ? 5 : 2 },
note: `synthetic score for iteration ${iteration}`,
}),
});
const snap = snapshotWith({
pipeline: {
stages: [{
id: 'critique',
atoms: ['critique-theater'],
repeat: true,
until: 'critique.score >= 5 || iterations >= 8',
}],
},
});
const outcomes = await runPipelineForRun({
db,
runId: 'run-stage-d',
projectId: 'project-1',
conversationId: 'conv-A',
snapshot: snap,
pipeline: snap.pipeline!,
env: { maxIterations: 8 },
runStage: async ({ stage, iteration, snapshot: snap2 }) => runStageWithRegistry({
db,
runId: 'run-stage-d',
projectId: 'project-1',
conversationId: 'conv-A',
stage,
iteration,
snapshot: snap2,
}),
});
expect(outcomes).toHaveLength(1);
expect(outcomes[0]?.converged).toBe(true);
// Iter 0: worker returns 2 → 2 < 5 (fail).
// Iter 1: worker returns 2 → 2 < 5 (fail).
// Iter 2: worker returns 5 → 5 >= 5 (pass).
expect(outcomes[0]?.iterations).toBe(3);
expect(outcomes[0]?.termination).toBe('until-satisfied');
});
it('falls through to permissive defaults when no atom worker contradicts them', async () => {
const snap = snapshotWith({
pipeline: {
stages: [{ id: 'compose', atoms: ['unknown-atom'] }],
},
});
const outcomes = await runPipelineForRun({
db,
runId: 'run-permissive',
projectId: 'project-1',
conversationId: 'conv-A',
snapshot: snap,
pipeline: snap.pipeline!,
env: { maxIterations: 3 },
runStage: async ({ stage, iteration, snapshot: snap2 }) => {
return runStageWithRegistry({
db,
runId: 'run-permissive',
projectId: 'project-1',
conversationId: 'conv-A',
stage,
iteration,
snapshot: snap2,
});
},
});
expect(outcomes[0]?.converged).toBe(true);
expect(outcomes[0]?.iterations).toBe(1);
expect(outcomes[0]?.termination).toBe('no-loop');
});
});
describe('pipeline-runner: GenUI cross-conversation cache (e2e-5)', () => {
it('reuses a project-tier surface answer across conversations without re-broadcasting', async () => {
const surface: GenUISurfaceSpec = {

View file

@ -2,7 +2,9 @@ import { useEffect, useMemo, useRef, useState, useTransition } from 'react';
import { useT } from '../i18n';
import type { Dict } from '../i18n/types';
import { projectFileUrl } from '../providers/registry';
import type { PluginInstallOutcome } from '../state/projects';
import type { LiveArtifactWorkspaceEntry, ProjectFile, ProjectFileKind } from '../types';
import { getPluginFolderCandidates } from './design-files/pluginFolders';
import { Icon } from './Icon';
import { LiveArtifactBadges } from './LiveArtifactBadges';
@ -21,6 +23,7 @@ interface Props {
onUploadFiles: (files: File[]) => void;
onPaste: () => void;
onNewSketch: () => void;
onInstallPluginFolder?: (relativePath: string) => Promise<PluginInstallOutcome>;
}
type Section = 'pages' | 'scripts' | 'images' | 'sketches' | 'other';
@ -56,6 +59,7 @@ export function DesignFilesPanel({
onUploadFiles,
onPaste,
onNewSketch,
onInstallPluginFolder,
}: Props) {
const t = useT();
const [refreshing, setRefreshing] = useState(false);
@ -70,6 +74,8 @@ export function DesignFilesPanel({
const [isSectionExpansionPending, startSectionExpansion] = useTransition();
const [selected, setSelected] = useState<Set<string>>(new Set());
const [deleting, setDeleting] = useState(false);
const [installingFolder, setInstallingFolder] = useState<string | null>(null);
const [installNotice, setInstallNotice] = useState<string | null>(null);
const grouped = useMemo(() => {
const groups: Record<Section, ProjectFile[]> = {
@ -86,6 +92,8 @@ export function DesignFilesPanel({
return groups;
}, [files]);
const pluginFolders = useMemo(() => getPluginFolderCandidates(files), [files]);
// Prune selections that no longer exist in the current file list
// (e.g. after a refresh or delete within the same project).
// Cross-project leaks are handled by the parent remounting this
@ -224,6 +232,22 @@ export function DesignFilesPanel({
if (dropped.length > 0) onUploadFiles(dropped);
}
async function handleInstallPluginFolder(relativePath: string) {
if (!onInstallPluginFolder || installingFolder) return;
setInstallNotice(null);
setInstallingFolder(relativePath);
try {
const outcome = await onInstallPluginFolder(relativePath);
setInstallNotice(
outcome.message ??
(outcome.ok ? 'Installed plugin into My plugins.' : 'Plugin install failed.'),
);
if (outcome.ok) await onRefreshFiles();
} finally {
setInstallingFolder(null);
}
}
return (
<div className={`df-panel ${preview ? '' : 'no-preview'}`}>
<div className="df-main">
@ -329,6 +353,52 @@ export function DesignFilesPanel({
))}
</div>
) : null}
{pluginFolders.length > 0 ? (
<div className="df-section" key="plugin-folders">
<div className="df-section-label">
Plugin folders
<span className="df-section-count">{pluginFolders.length}</span>
</div>
{installNotice ? (
<div className="df-inline-notice" role="status">{installNotice}</div>
) : null}
{pluginFolders.map((folder) => (
<div
key={folder.path}
className="df-row df-row-plugin-folder"
data-testid={`design-plugin-folder-${folder.path}`}
>
<button
type="button"
className="df-row-folder-main"
onClick={() => setPreview(folder.manifestPath)}
>
<span className="df-row-icon" data-kind="folder" aria-hidden>
DIR
</span>
<span className="df-row-name-wrap">
<span className="df-row-name">{folder.path}</span>
<span className="df-row-sub">
{folder.fileCount} files · ready to add to My plugins
</span>
</span>
</button>
<span className="df-row-time">{relativeTime(folder.updatedAt, t)}</span>
{onInstallPluginFolder ? (
<button
type="button"
className="df-plugin-install"
data-testid={`design-plugin-folder-install-${folder.path}`}
disabled={installingFolder !== null}
onClick={() => void handleInstallPluginFolder(folder.path)}
>
{installingFolder === folder.path ? 'Adding…' : 'Add to My plugins'}
</button>
) : null}
</div>
))}
</div>
) : null}
{SECTION_ORDER.filter((s) => grouped[s].length > 0).map((section) => {
const sectionFiles = grouped[section];
const visibleLimit = sectionLimits[section] ?? INITIAL_SECTION_FILE_LIMIT;

View file

@ -37,6 +37,10 @@ import { EntryNavRail, type EntryView as EntryViewKind } from './EntryNavRail';
import { GithubStarBadge } from './GithubStarBadge';
import { formatStars, GITHUB_REPO_URL, useGithubStars } from './useGithubStars';
import { HomeView } from './HomeView';
import {
createPluginAuthoringHandoff,
type HomePromptHandoff,
} from './home-hero/plugin-authoring';
import { Icon } from './Icon';
import { IntegrationsView, type IntegrationTab } from './IntegrationsView';
import { InlineModelSwitcher } from './InlineModelSwitcher';
@ -237,6 +241,7 @@ export function EntryShell({
const [appearanceExpanded, setAppearanceExpanded] = useState(false);
const [newProjectOpen, setNewProjectOpen] = useState(false);
const [integrationTab, setIntegrationTab] = useState<IntegrationTab>(integrationInitialTab);
const [homePromptHandoff, setHomePromptHandoff] = useState<HomePromptHandoff | null>(null);
const avatarMenuRef = useRef<HTMLDivElement | null>(null);
// Star count + active-model summary are kept in render scope so
// the dropdown's collapsed rows can mirror what the chips show
@ -252,6 +257,11 @@ export function EntryShell({
navigate({ kind: 'home', view: next });
}
function startPluginAuthoring() {
setHomePromptHandoff(createPluginAuthoringHandoff(Date.now()));
changeView('home');
}
useEffect(() => {
setIntegrationTab(integrationInitialTab);
}, [integrationInitialTab]);
@ -656,6 +666,7 @@ export function EntryShell({
void tab;
setNewProjectOpen(true);
}}
promptHandoff={homePromptHandoff}
/>
) : null}
{view === 'projects' ? (
@ -683,7 +694,9 @@ export function EntryShell({
onOpenOrbitSettings={() => onOpenSettings('orbit')}
/>
) : null}
{view === 'plugins' ? <PluginsView /> : null}
{view === 'plugins' ? (
<PluginsView onCreatePlugin={startPluginAuthoring} />
) : null}
{view === 'design-systems' ? (
designSystemsLoading ? (
<CenteredLoader label={t('common.loading')} />

View file

@ -12,6 +12,7 @@ import {
uploadProjectFiles,
writeProjectTextFile,
} from '../providers/registry';
import { installGeneratedPluginFolder } from '../state/projects';
import {
type ChatCommentAttachment,
liveArtifactSummaryToWorkspaceEntry,
@ -604,6 +605,9 @@ export function FileWorkspace({
onUploadFiles={(picked) => void uploadFiles(picked)}
onPaste={() => setShowPasteDialog(true)}
onNewSketch={startNewSketch}
onInstallPluginFolder={(relativePath) =>
installGeneratedPluginFolder(projectId, relativePath)
}
/>
) : isActiveSketch && activeSketch && activeFile ? (
activeSketch.loaded ? (

View file

@ -31,6 +31,7 @@ interface Props {
pluginsLoading: boolean;
pendingPluginId: string | null;
pendingChipId: string | null;
submitDisabled?: boolean;
onPickPlugin: (record: InstalledPluginRecord, nextPrompt: string | null) => void;
onPickChip: (chip: HomeHeroChip) => void;
contextItemCount: number;
@ -49,6 +50,7 @@ export const HomeHero = forwardRef<HTMLTextAreaElement, Props>(function HomeHero
pluginsLoading,
pendingPluginId,
pendingChipId,
submitDisabled = false,
onPickPlugin,
onPickChip,
contextItemCount,
@ -57,7 +59,7 @@ export const HomeHero = forwardRef<HTMLTextAreaElement, Props>(function HomeHero
ref,
) {
const [selectedIndex, setSelectedIndex] = useState(0);
const canSubmit = prompt.trim().length > 0;
const canSubmit = prompt.trim().length > 0 && !submitDisabled;
const placeholder = activePluginTitle
? 'Edit the example query or write your own…'
: 'What do you want to design? Type a prompt, @search a plugin, or pick one below…';

View file

@ -23,6 +23,10 @@ import { useI18n } from '../i18n';
import type { Project } from '../types';
import { HomeHero } from './HomeHero';
import type { HomeHeroChip } from './home-hero/chips';
import {
PLUGIN_AUTHORING_PROMPT,
type HomePromptHandoff,
} from './home-hero/plugin-authoring';
import { PluginDetailsModal } from './PluginDetailsModal';
import { PluginsHomeSection } from './PluginsHomeSection';
import type { PluginLoopSubmit } from './PluginLoopHome';
@ -52,6 +56,7 @@ interface Props {
// through so the dispatcher can stay declarative.
onImportFolder?: () => Promise<void> | void;
onOpenNewProject?: (tab: 'template') => void;
promptHandoff?: HomePromptHandoff | null;
}
export function HomeView({
@ -62,30 +67,52 @@ export function HomeView({
onViewAllProjects,
onImportFolder,
onOpenNewProject,
promptHandoff,
}: Props) {
const { locale } = useI18n();
const [plugins, setPlugins] = useState<InstalledPluginRecord[]>([]);
const [pluginsLoading, setPluginsLoading] = useState(true);
const [pendingApplyId, setPendingApplyId] = useState<string | null>(null);
const [pendingChipId, setPendingChipId] = useState<string | null>(null);
const [pendingAuthoringChipId, setPendingAuthoringChipId] = useState<string | null>(null);
const [active, setActive] = useState<ActivePlugin | null>(null);
const [prompt, setPrompt] = useState('');
const [error, setError] = useState<string | null>(null);
const [detailsRecord, setDetailsRecord] = useState<InstalledPluginRecord | null>(null);
const inputRef = useRef<HTMLTextAreaElement | null>(null);
const consumedHandoffIdRef = useRef<number | null>(null);
useEffect(() => {
let cancelled = false;
void listPlugins().then((rows) => {
if (cancelled) return;
setPlugins(rows);
setPluginsLoading(false);
});
const load = () => {
void listPlugins().then((rows) => {
if (cancelled) return;
setPlugins(rows);
setPluginsLoading(false);
});
};
load();
window.addEventListener('open-design:plugins-changed', load);
return () => {
cancelled = true;
window.removeEventListener('open-design:plugins-changed', load);
};
}, []);
useEffect(() => {
if (!promptHandoff || consumedHandoffIdRef.current === promptHandoff.id) return;
consumedHandoffIdRef.current = promptHandoff.id;
setActive(null);
setError(null);
setPrompt(promptHandoff.prompt);
if (promptHandoff.focus) {
requestAnimationFrame(() => inputRef.current?.focus());
}
if (promptHandoff.source === 'plugin-authoring') {
setPendingAuthoringChipId('plugin-authoring');
}
}, [promptHandoff]);
const contextItemCount = useMemo(
() => active?.result.contextItems?.length ?? 0,
[active],
@ -132,6 +159,30 @@ export function HomeView({
setPrompt('');
}
function queuePluginAuthoring(chipId: string | null) {
setActive(null);
setPrompt(PLUGIN_AUTHORING_PROMPT);
setPendingAuthoringChipId(chipId ?? 'plugin-authoring');
requestAnimationFrame(() => inputRef.current?.focus());
}
useEffect(() => {
if (!pendingAuthoringChipId || pluginsLoading) return;
const record = plugins.find((plugin) => plugin.id === 'od-plugin-authoring');
setPendingAuthoringChipId(null);
if (!record) {
setError(
'Bundled scenario "od-plugin-authoring" is not installed. Reinstall the daemon to restore the default plugin set.',
);
return;
}
void usePlugin(record, PLUGIN_AUTHORING_PROMPT, {
projectKind: 'other',
chipId: pendingAuthoringChipId === 'plugin-authoring' ? undefined : pendingAuthoringChipId,
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pendingAuthoringChipId, pluginsLoading, plugins]);
// Stage B of plugin-driven-flow-plan: the chip rail dispatcher.
// Pure UI-state mapping — the heavy lifting (apply / import) is
// delegated back to existing handlers. Migration chips that don't
@ -156,6 +207,10 @@ export function HomeView({
});
return;
}
case 'create-plugin': {
queuePluginAuthoring(chip.id);
return;
}
case 'import-folder': {
if (!onImportFolder) {
setError('Folder import is not available in this shell.');
@ -202,6 +257,7 @@ export function HomeView({
pluginsLoading={pluginsLoading}
pendingPluginId={pendingApplyId}
pendingChipId={pendingChipId}
submitDisabled={Boolean(pendingApplyId) || Boolean(pendingAuthoringChipId)}
onPickPlugin={(record, nextPrompt) => void usePlugin(record, nextPrompt)}
onPickChip={pickChip}
contextItemCount={contextItemCount}

View file

@ -38,7 +38,7 @@ const PLUGINS_TABS: ReadonlyArray<{
{ id: 'team', label: 'Team / Enterprise', hint: 'Coming soon' },
];
export function PluginsView() {
export function PluginsView({ onCreatePlugin }: { onCreatePlugin?: () => void }) {
const { locale } = useI18n();
const [plugins, setPlugins] = useState<InstalledPluginRecord[]>([]);
const [marketplaces, setMarketplaces] = useState<PluginMarketplace[]>([]);
@ -63,6 +63,8 @@ export function PluginsView() {
useEffect(() => {
void refresh();
window.addEventListener('open-design:plugins-changed', refresh);
return () => window.removeEventListener('open-design:plugins-changed', refresh);
}, []);
const officialPlugins = useMemo(
@ -123,12 +125,21 @@ export function PluginsView() {
<button
type="button"
className="plugins-view__primary"
onClick={onCreatePlugin}
data-testid="plugins-create-button"
>
<Icon name="edit" size={13} />
<span>Create plugin</span>
</button>
<button
type="button"
className="plugins-view__secondary"
onClick={() => setImportOpen(true)}
aria-haspopup="dialog"
data-testid="plugins-import-button"
>
<Icon name="plus" size={13} />
<span>Create / Import</span>
<span>Import plugin</span>
</button>
<div className="plugins-view__badge" aria-hidden="true">
<Icon name="grid" size={15} />

View file

@ -0,0 +1,39 @@
import type { ProjectFile } from '../../types';
export interface PluginFolderCandidate {
path: string;
fileCount: number;
updatedAt: number;
manifestPath: string;
}
export function getPluginFolderCandidates(files: ProjectFile[]): PluginFolderCandidate[] {
const byFolder = new Map<string, ProjectFile[]>();
for (const file of files) {
const slash = file.name.lastIndexOf('/');
if (slash <= 0) continue;
const folder = file.name.slice(0, slash);
const topFolder = folder.split('/')[0];
if (!topFolder) continue;
const rows = byFolder.get(topFolder) ?? [];
rows.push(file);
byFolder.set(topFolder, rows);
}
const candidates: PluginFolderCandidate[] = [];
for (const [folder, rows] of byFolder) {
const names = new Set(rows.map((row) => row.name));
const manifestPath = `${folder}/open-design.json`;
const hasManifest = names.has(manifestPath);
const hasSkill = names.has(`${folder}/SKILL.md`);
if (!hasManifest || !hasSkill) continue;
candidates.push({
path: folder,
fileCount: rows.length,
updatedAt: Math.max(...rows.map((row) => row.mtime)),
manifestPath,
});
}
return candidates.sort((a, b) => b.updatedAt - a.updatedAt);
}

View file

@ -22,9 +22,22 @@ import type { ProjectKind } from '@open-design/contracts';
import type { DefaultScenarioPluginId } from '@open-design/contracts';
import type { IconName } from '../Icon';
// Plugin ids the chip rail can dispatch to. Most chips route to a
// `DefaultScenarioPluginId` so the same fallback table the daemon
// uses for naked Home queries stays the source of truth. Specialised
// chips (HyperFrames lives under `plugins/_official/examples/hyperframes/`
// and surfaces as the `example-hyperframes` bundled plugin id) bypass
// the default table by carrying their own plugin id directly. The
// curated union keeps typo safety while letting the rail evolve
// independently of the default-binding mapping.
export type ChipScenarioPluginId =
| DefaultScenarioPluginId
| 'example-hyperframes';
export type ChipAction =
| { kind: 'apply-scenario'; pluginId: DefaultScenarioPluginId; projectKind: ProjectKind }
| { kind: 'apply-scenario'; pluginId: ChipScenarioPluginId; projectKind: ProjectKind }
| { kind: 'apply-figma-migration'; pluginId: 'od-figma-migration'; projectKind: ProjectKind }
| { kind: 'create-plugin' }
| { kind: 'import-folder' }
| { kind: 'open-template-picker' };
@ -52,6 +65,19 @@ export const HOME_HERO_CHIPS: ReadonlyArray<HomeHeroChip> = [
group: 'create',
action: { kind: 'apply-scenario', pluginId: 'od-new-generation', projectKind: 'prototype' },
},
{
id: 'live-artifact',
label: 'Live artifact',
icon: 'pencil',
group: 'create',
hint: 'Build an interactive HTML/CSS/JS artifact you can preview live.',
// No dedicated scenario plugin yet — the live-artifact authoring
// flow shares od-new-generation's pipeline (file-write + live-
// artifact atoms). We still surface it as a separate chip so the
// user can pick their target surface up front instead of routing
// through Prototype + a metadata flip.
action: { kind: 'apply-scenario', pluginId: 'od-new-generation', projectKind: 'prototype' },
},
{
id: 'deck',
label: 'Slide deck',
@ -73,6 +99,18 @@ export const HOME_HERO_CHIPS: ReadonlyArray<HomeHeroChip> = [
group: 'create',
action: { kind: 'apply-scenario', pluginId: 'od-media-generation', projectKind: 'video' },
},
{
id: 'hyperframes',
label: 'HyperFrames',
icon: 'orbit',
group: 'create',
hint: 'Author HTML-based motion: captions, audio-reactive visuals, scene transitions.',
// HyperFrames is its own bundled scenario (motion-graphics
// specialisation of Video). It surfaces in PluginsHomeSection's
// primary category list, so the rail picks it up too rather than
// hiding the specialised bucket behind the generic Video chip.
action: { kind: 'apply-scenario', pluginId: 'example-hyperframes', projectKind: 'video' },
},
{
id: 'audio',
label: 'Audio',
@ -87,6 +125,14 @@ export const HOME_HERO_CHIPS: ReadonlyArray<HomeHeroChip> = [
group: 'create',
action: { kind: 'apply-scenario', pluginId: 'od-new-generation', projectKind: 'other' },
},
{
id: 'create-plugin',
label: 'Create plugin',
icon: 'edit',
group: 'create',
hint: 'Author a reusable Open Design plugin and add it to My plugins.',
action: { kind: 'create-plugin' },
},
{
id: 'figma',
label: 'From Figma',

View file

@ -0,0 +1,26 @@
export interface HomePromptHandoff {
id: number;
prompt: string;
focus: boolean;
source: 'plugin-authoring';
}
export const PLUGIN_AUTHORING_PROMPT = [
'Create an Open Design plugin for: <describe the workflow you want to package>.',
'',
'Follow docs/plugins-spec.md and produce a folder named generated-plugin with:',
'- SKILL.md describing the agent behavior and workflow',
'- open-design.json with valid metadata, mode, task kind, inputs, and any pipeline/context references',
'- optional examples/ and assets/ when useful',
'',
'When finished, summarize the files created and whether the folder is ready to add to My plugins.',
].join('\n');
export function createPluginAuthoringHandoff(id: number): HomePromptHandoff {
return {
id,
prompt: PLUGIN_AUTHORING_PROMPT,
focus: true,
source: 'plugin-authoring',
};
}

View file

@ -7149,6 +7149,58 @@ button.connector-action.is-loading {
.df-row-live-artifact {
grid-template-columns: 36px minmax(0, 1fr) auto;
}
.df-row-plugin-folder {
grid-template-columns: minmax(0, 1fr) auto auto;
cursor: default;
}
.df-row-folder-main {
appearance: none;
display: grid;
grid-template-columns: 36px minmax(0, 1fr);
align-items: center;
gap: 12px;
min-width: 0;
padding: 0;
border: 0;
background: transparent;
color: inherit;
cursor: pointer;
font: inherit;
text-align: left;
}
.df-plugin-install {
appearance: none;
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 28px;
padding: 0 10px;
border: 1px solid var(--border);
border-radius: 999px;
background: var(--bg-panel);
color: var(--text-muted);
cursor: pointer;
font: inherit;
font-size: 11px;
font-weight: 600;
}
.df-plugin-install:hover:not(:disabled) {
border-color: var(--accent);
color: var(--accent);
}
.df-plugin-install:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.df-inline-notice {
margin: 0 20px 6px;
padding: 8px 10px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg-subtle);
color: var(--text-muted);
font-size: 12px;
}
.df-row:hover { background: var(--bg-subtle); }
.df-row.active { background: var(--blue-bg); color: var(--text); }
.df-row.active .df-row-name { color: var(--text-strong); }

View file

@ -11,6 +11,8 @@ import type {
ImportFolderRequest,
ImportFolderResponse,
InstalledPluginRecord,
PluginInstallOutcome,
ProjectPluginFolderInstallRequest,
} from '@open-design/contracts';
import { randomUUID } from '../utils/uuid';
import type {
@ -22,6 +24,8 @@ import type {
ProjectTemplate,
} from '../types';
export type { PluginInstallOutcome } from '@open-design/contracts';
export async function listProjects(): Promise<Project[]> {
try {
const resp = await fetch('/api/projects');
@ -365,14 +369,6 @@ export async function listPlugins(): Promise<InstalledPluginRecord[]> {
}
}
export interface PluginInstallOutcome {
ok: boolean;
plugin?: InstalledPluginRecord;
warnings: string[];
message?: string;
log: string[];
}
interface PluginInstallEvent {
kind?: 'progress' | 'success' | 'error';
phase?: string;
@ -444,6 +440,35 @@ export async function uploadPluginFolder(files: File[]): Promise<PluginInstallOu
return postPluginUpload('/api/plugins/upload-folder', form);
}
export async function installGeneratedPluginFolder(
projectId: string,
relativePath: string,
): Promise<PluginInstallOutcome> {
try {
const request: ProjectPluginFolderInstallRequest = { path: relativePath };
const resp = await fetch(
`/api/projects/${encodeURIComponent(projectId)}/plugins/install-folder`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(request),
},
);
const outcome = await readPluginInstallOutcome(resp);
if (outcome.ok && typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent('open-design:plugins-changed'));
}
return outcome;
} catch (err) {
return {
ok: false,
warnings: [],
message: (err as Error).message,
log: [],
};
}
}
export async function upgradePlugin(id: string): Promise<PluginInstallOutcome> {
const log: string[] = [];
try {
@ -526,6 +551,32 @@ async function postPluginUpload(url: string, form: FormData): Promise<PluginInst
}
}
async function readPluginInstallOutcome(resp: Response): Promise<PluginInstallOutcome> {
const json = (await resp.json()) as Partial<PluginInstallOutcome> & {
error?: string | { message?: string };
};
if (resp.ok && json.ok) {
return {
ok: true,
...(json.plugin ? { plugin: json.plugin } : {}),
warnings: json.warnings ?? [],
message: json.message ?? 'Plugin installed.',
log: json.log ?? [],
};
}
const message =
json.message ??
(typeof json.error === 'string' ? json.error : json.error?.message) ??
resp.statusText;
return {
ok: false,
...(json.plugin ? { plugin: json.plugin } : {}),
warnings: json.warnings ?? [],
message,
log: json.log ?? [],
};
}
function getUploadRelativePath(file: File): string {
const withRelativePath = file as File & { webkitRelativePath?: string };
return withRelativePath.webkitRelativePath || file.name;

View file

@ -286,59 +286,107 @@
/* ------------------------------------------------------------
Stage B of plugin-driven-flow-plan: Home intent rail.
A horizontal chip strip below the input card mirroring the
NewProject taxonomy plus migration shortcuts (Figma / folder /
template). Clicking a chip pre-applies the matching scenario
plugin (or dispatches to the folder / template flow) so Enter
behaves the same as the explicit "Use plugin" path.
Two flex sub-groups ("create" outputs vs. "migrate" sources)
separated by a hairline divider. The whole rail wraps cleanly
onto multiple rows on narrow viewports no horizontal scroll.
Visual language matches the active-plugin chip inside the
input card (pill, accent-tint when selected), and the migrate
variant uses the page's --bg-subtle surface so secondary
intents recede without losing affordance.
------------------------------------------------------------ */
.home-hero__rail {
width: 100%;
max-width: 720px;
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
justify-content: center;
padding: 2px;
gap: 8px 12px;
padding: 4px 2px 0;
}
.home-hero__rail-group {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 6px;
}
.home-hero__rail-divider {
display: inline-block;
width: 1px;
height: 18px;
background: var(--border-soft);
border-radius: 1px;
flex: 0 0 auto;
}
.home-hero__rail-chip {
appearance: none;
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 11px;
padding: 5px 11px 5px 10px;
border: 1px solid var(--border);
border-radius: 999px;
background: var(--bg-panel);
color: var(--text-muted);
font-size: 12px;
font-size: 12.5px;
font-weight: 500;
line-height: 1;
letter-spacing: -0.005em;
cursor: pointer;
transition: border-color 120ms ease, background-color 120ms ease, color 120ms ease, transform 120ms ease;
box-shadow: var(--shadow-xs);
transition: border-color 140ms ease, background-color 140ms ease,
color 140ms ease, transform 140ms ease, box-shadow 140ms ease;
}
.home-hero__rail-chip--migrate {
background: var(--bg-subtle);
border-color: color-mix(in srgb, var(--border) 70%, transparent);
}
.home-hero__rail-chip-icon {
flex: 0 0 auto;
color: currentColor;
opacity: 0.82;
transition: opacity 140ms ease;
}
.home-hero__rail-chip-label {
white-space: nowrap;
}
.home-hero__rail-chip:hover:not(:disabled) {
border-color: var(--border-strong);
color: var(--text);
transform: translateY(-0.5px);
background: var(--bg-panel);
transform: translateY(-1px);
box-shadow: var(--shadow-sm);
}
.home-hero__rail-chip:hover:not(:disabled) .home-hero__rail-chip-icon {
opacity: 1;
}
.home-hero__rail-chip:focus-visible {
outline: none;
border-color: color-mix(in srgb, var(--accent) 40%, var(--border));
box-shadow: 0 0 0 3px var(--accent-tint);
}
.home-hero__rail-chip:disabled {
cursor: not-allowed;
opacity: 0.55;
box-shadow: none;
transform: none;
}
.home-hero__rail-chip.is-active {
background: var(--accent-tint);
border-color: color-mix(in srgb, var(--accent) 40%, var(--border));
color: var(--accent);
box-shadow:
0 1px 0 color-mix(in srgb, var(--accent) 18%, transparent),
var(--shadow-xs);
transform: translateY(-1px);
}
.home-hero__rail-chip.is-active .home-hero__rail-chip-icon {
opacity: 1;
}
.home-hero__rail-chip.is-pending {
opacity: 0.75;
}
.home-hero__rail-chip-icon {
flex: 0 0 auto;
color: currentColor;
}
.home-hero__rail-chip-label {
white-space: nowrap;
box-shadow: none;
}
@media (max-width: 900px) {
@ -346,6 +394,13 @@
font-size: 24px;
}
/* Plan §B follow-up: chip rail must never require horizontal
scrolling. Keep `flex-wrap: wrap` from the base rule so chips
fall onto a second row instead of hiding behind an overflow. */
scrolling. The base rule already wraps; on narrow viewports
the two groups stack onto separate rows, so the divider
becomes visual noise hide it. */
.home-hero__rail-divider {
display: none;
}
.home-hero__rail {
gap: 6px 8px;
}
}

View file

@ -6,6 +6,7 @@ import { renderToStaticMarkup } from 'react-dom/server';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { FileWorkspace, scrollWorkspaceTabsWithWheel } from '../../src/components/FileWorkspace';
import { DesignFilesPanel } from '../../src/components/DesignFilesPanel';
import { projectSplitClassName } from '../../src/components/ProjectView';
import type { ProjectFile } from '../../src/types';
@ -173,6 +174,48 @@ describe('FileWorkspace upload input', () => {
});
});
describe('DesignFilesPanel plugin folders', () => {
it('surfaces generated plugin folders with an install action', async () => {
const onInstallPluginFolder = vi.fn(async () => ({
ok: true,
warnings: [],
message: 'Installed Generated Plugin.',
log: [],
}));
const container = renderWorkspace(
<DesignFilesPanel
projectId="project-1"
files={[
workspaceFile('generated-plugin/open-design.json'),
workspaceFile('generated-plugin/SKILL.md'),
workspaceFile('generated-plugin/examples/demo.md'),
]}
liveArtifacts={[]}
onRefreshFiles={vi.fn()}
onOpenFile={vi.fn()}
onOpenLiveArtifact={vi.fn()}
onDeleteFile={vi.fn()}
onDeleteFiles={vi.fn()}
onUpload={vi.fn()}
onUploadFiles={vi.fn()}
onPaste={vi.fn()}
onNewSketch={vi.fn()}
onInstallPluginFolder={onInstallPluginFolder}
/>,
);
expect(container.querySelector('[data-testid="design-plugin-folder-generated-plugin"]')).toBeTruthy();
const install = container.querySelector<HTMLButtonElement>(
'[data-testid="design-plugin-folder-install-generated-plugin"]',
);
expect(install).toBeTruthy();
await act(async () => {
install?.click();
});
expect(onInstallPluginFolder).toHaveBeenCalledWith('generated-plugin');
});
});
describe('FileWorkspace tab reordering', () => {
it('persists a dragged file tab before the tab it is dropped on', () => {
const onTabsStateChange = vi.fn();

View file

@ -81,6 +81,7 @@ describe('HomeHero intent rail', () => {
});
it('migration chips carry the right action discriminator', () => {
expect(findChip('create-plugin')?.action).toMatchObject({ kind: 'create-plugin' });
expect(findChip('figma')?.action).toMatchObject({ kind: 'apply-figma-migration' });
expect(findChip('folder')?.action).toMatchObject({ kind: 'import-folder' });
expect(findChip('template')?.action).toMatchObject({ kind: 'open-template-picker' });
@ -101,4 +102,23 @@ describe('HomeHero intent rail', () => {
expect(findChip('deck')?.action).toMatchObject({ pluginId: 'od-new-generation', projectKind: 'deck' });
expect(findChip('other')?.action).toMatchObject({ pluginId: 'od-new-generation', projectKind: 'other' });
});
it('specialised category chips route to their bundled scenario plugin', () => {
// HyperFrames is the motion-graphics specialisation of Video,
// surfaced as a separate chip so users can target it directly
// instead of routing through the generic Video chip.
expect(findChip('hyperframes')?.action).toMatchObject({
kind: 'apply-scenario',
pluginId: 'example-hyperframes',
projectKind: 'video',
});
// Live artifact reuses od-new-generation's pipeline today but
// keeps a distinct chip id + label so the rail's active state
// tracks user intent independently from the Prototype chip.
expect(findChip('live-artifact')?.action).toMatchObject({
kind: 'apply-scenario',
pluginId: 'od-new-generation',
projectKind: 'prototype',
});
});
});

View file

@ -0,0 +1,241 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { HomeView } from '../../src/components/HomeView';
import {
createPluginAuthoringHandoff,
PLUGIN_AUTHORING_PROMPT,
} from '../../src/components/home-hero/plugin-authoring';
const AUTHORING_PLUGIN = {
id: 'od-plugin-authoring',
title: 'Plugin authoring',
version: '0.1.0',
trust: 'bundled' as const,
sourceKind: 'bundled' as const,
source: '/tmp/plugin-authoring',
capabilitiesGranted: ['prompt:inject'],
fsPath: '/tmp/plugin-authoring',
installedAt: 0,
updatedAt: 0,
manifest: {
name: 'od-plugin-authoring',
title: 'Plugin authoring',
version: '0.1.0',
description: 'Create plugins',
od: {
kind: 'scenario',
taskKind: 'new-generation',
useCase: { query: 'Create a plugin.' },
},
},
};
const AUTHORING_APPLY_RESULT = {
query: 'Create a plugin.',
contextItems: [],
inputs: [],
assets: [],
mcpServers: [],
trust: 'trusted',
capabilitiesGranted: ['prompt:inject'],
capabilitiesRequired: ['prompt:inject'],
appliedPlugin: {
snapshotId: 'snap-authoring',
pluginId: 'od-plugin-authoring',
pluginVersion: '0.1.0',
manifestSourceDigest: 'a'.repeat(64),
inputs: {},
resolvedContext: { items: [] },
capabilitiesGranted: ['prompt:inject'],
capabilitiesRequired: ['prompt:inject'],
assetsStaged: [],
taskKind: 'new-generation',
appliedAt: 0,
connectorsRequired: [],
connectorsResolved: [],
mcpServers: [],
status: 'fresh',
},
projectMetadata: {},
};
describe('HomeView prompt handoff', () => {
afterEach(() => {
vi.unstubAllGlobals();
cleanup();
});
it('consumes a plugin authoring handoff once and focuses the textarea', async () => {
vi.stubGlobal('fetch', vi.fn(async () => (
new Response(JSON.stringify({ plugins: [] }), {
status: 200,
headers: { 'content-type': 'application/json' },
})
)));
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
cb(0);
return 0;
});
const { rerender } = render(
<HomeView
projects={[]}
onSubmit={() => undefined}
onOpenProject={() => undefined}
onViewAllProjects={() => undefined}
promptHandoff={createPluginAuthoringHandoff(1)}
/>,
);
const input = await screen.findByTestId('home-hero-input');
await waitFor(() => {
expect((input as HTMLTextAreaElement).value).toBe(PLUGIN_AUTHORING_PROMPT);
expect(document.activeElement).toBe(input);
});
fireEvent.change(input, { target: { value: 'User edited prompt' } });
rerender(
<HomeView
projects={[]}
onSubmit={() => undefined}
onOpenProject={() => undefined}
onViewAllProjects={() => undefined}
promptHandoff={createPluginAuthoringHandoff(1)}
/>,
);
expect((input as HTMLTextAreaElement).value).toBe('User edited prompt');
});
it('uses the same authoring prompt from the Home rail chip', async () => {
vi.stubGlobal('fetch', vi.fn(async () => (
new Response(JSON.stringify({ plugins: [] }), {
status: 200,
headers: { 'content-type': 'application/json' },
})
)));
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
cb(0);
return 0;
});
render(
<HomeView
projects={[]}
onSubmit={() => undefined}
onOpenProject={() => undefined}
onViewAllProjects={() => undefined}
/>,
);
fireEvent.click(await screen.findByTestId('home-hero-rail-create-plugin'));
const input = await screen.findByTestId('home-hero-input');
await waitFor(() => {
expect((input as HTMLTextAreaElement).value).toBe(PLUGIN_AUTHORING_PROMPT);
expect(document.activeElement).toBe(input);
});
});
it('binds od-plugin-authoring before submitting the rail create-plugin prompt', async () => {
const fetchMock = vi.fn<typeof fetch>(async (url) => {
if (typeof url === 'string' && url === '/api/plugins') {
return new Response(JSON.stringify({ plugins: [AUTHORING_PLUGIN] }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (typeof url === 'string' && url.includes('/apply')) {
return new Response(JSON.stringify(AUTHORING_APPLY_RESULT), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
throw new Error(`unexpected fetch ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
cb(0);
return 0;
});
const onSubmit = vi.fn();
render(
<HomeView
projects={[]}
onSubmit={onSubmit}
onOpenProject={() => undefined}
onViewAllProjects={() => undefined}
/>,
);
fireEvent.click(await screen.findByTestId('home-hero-rail-create-plugin'));
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(
'/api/plugins/od-plugin-authoring/apply',
expect.anything(),
));
fireEvent.click(await screen.findByTestId('home-hero-submit'));
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({
prompt: PLUGIN_AUTHORING_PROMPT,
pluginId: 'od-plugin-authoring',
appliedPluginSnapshotId: 'snap-authoring',
projectKind: 'other',
}));
});
it('does not submit the create-plugin prompt before the authoring scenario is applied', async () => {
let resolveApply: (response: Response) => void = () => undefined;
const applyResponse = new Promise<Response>((resolve) => {
resolveApply = resolve;
});
const fetchMock = vi.fn<typeof fetch>(async (url) => {
if (typeof url === 'string' && url === '/api/plugins') {
return new Response(JSON.stringify({ plugins: [AUTHORING_PLUGIN] }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (typeof url === 'string' && url.includes('/apply')) {
return applyResponse;
}
throw new Error(`unexpected fetch ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
cb(0);
return 0;
});
const onSubmit = vi.fn();
render(
<HomeView
projects={[]}
onSubmit={onSubmit}
onOpenProject={() => undefined}
onViewAllProjects={() => undefined}
/>,
);
fireEvent.click(await screen.findByTestId('home-hero-rail-create-plugin'));
fireEvent.click(await screen.findByTestId('home-hero-submit'));
expect(onSubmit).not.toHaveBeenCalled();
resolveApply(new Response(JSON.stringify(AUTHORING_APPLY_RESULT), {
status: 200,
headers: { 'content-type': 'application/json' },
}));
await waitFor(() => {
expect((screen.getByTestId('home-hero-submit') as HTMLButtonElement).disabled).toBe(false);
});
fireEvent.click(screen.getByTestId('home-hero-submit'));
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({
pluginId: 'od-plugin-authoring',
appliedPluginSnapshotId: 'snap-authoring',
}));
});
});

View file

@ -137,6 +137,16 @@ afterEach(() => {
});
describe('PluginsView', () => {
it('starts guided plugin creation from the Plugins hero', async () => {
const onCreatePlugin = vi.fn();
render(<PluginsView onCreatePlugin={onCreatePlugin} />);
fireEvent.click(await screen.findByTestId('plugins-create-button'));
expect(onCreatePlugin).toHaveBeenCalledTimes(1);
expect(screen.queryByRole('dialog', { name: 'Create or import a plugin' })).toBeNull();
});
it('groups community and user-installed plugins while keeping marketplaces coming soon', async () => {
render(<PluginsView />);

View file

@ -1,5 +1,5 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { applyPlugin } from '../../src/state/projects';
import { applyPlugin, installGeneratedPluginFolder } from '../../src/state/projects';
describe('applyPlugin', () => {
afterEach(() => {
@ -50,3 +50,59 @@ describe('applyPlugin', () => {
});
});
});
describe('installGeneratedPluginFolder', () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it('installs a project-relative generated plugin folder', async () => {
const dispatchEvent = vi.fn();
vi.stubGlobal('window', { dispatchEvent });
const fetchMock = vi.fn<typeof fetch>(async () => new Response(
JSON.stringify({
ok: true,
plugin: { id: 'generated-plugin', title: 'Generated Plugin' },
warnings: [],
message: 'Installed Generated Plugin.',
log: [],
}),
{ status: 200, headers: { 'content-type': 'application/json' } },
));
vi.stubGlobal('fetch', fetchMock);
const outcome = await installGeneratedPluginFolder('project-1', 'generated-plugin');
expect(outcome.ok).toBe(true);
expect(fetchMock).toHaveBeenCalledWith(
'/api/projects/project-1/plugins/install-folder',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ path: 'generated-plugin' }),
}),
);
expect(dispatchEvent).toHaveBeenCalled();
});
it('preserves install diagnostics from non-2xx project folder responses', async () => {
const fetchMock = vi.fn<typeof fetch>(async () => new Response(
JSON.stringify({
ok: false,
warnings: ['Missing open-design.json'],
message: 'Plugin validation failed.',
log: ['Validating generated-plugin'],
}),
{ status: 400, headers: { 'content-type': 'application/json' }, statusText: 'Bad Request' },
));
vi.stubGlobal('fetch', fetchMock);
const outcome = await installGeneratedPluginFolder('project-1', 'generated-plugin');
expect(outcome).toMatchObject({
ok: false,
warnings: ['Missing open-design.json'],
message: 'Plugin validation failed.',
log: ['Validating generated-plugin'],
});
});
});

View file

@ -49,6 +49,22 @@ export const PluginInstallSourceSchema = z.object({
export type PluginInstallSource = z.infer<typeof PluginInstallSourceSchema>;
export const PluginInstallOutcomeSchema = z.object({
ok: z.boolean(),
plugin: InstalledPluginRecordSchema.nullable().optional(),
warnings: z.array(z.string()),
message: z.string().optional(),
log: z.array(z.string()),
});
export type PluginInstallOutcome = z.infer<typeof PluginInstallOutcomeSchema>;
export const ProjectPluginFolderInstallRequestSchema = z.object({
path: z.string().min(1),
});
export type ProjectPluginFolderInstallRequest = z.infer<typeof ProjectPluginFolderInstallRequestSchema>;
// Re-export TrustTier so consumers can pull every plugin contract from one
// barrel without hopping through marketplace.ts.
export type { TrustTier };

View file

@ -23,6 +23,7 @@ export type TaskKind = AppliedPluginSnapshot['taskKind'];
export type DefaultScenarioPluginId =
| 'od-new-generation'
| 'od-media-generation'
| 'od-plugin-authoring'
| 'od-figma-migration'
| 'od-code-migration'
| 'od-tune-collab';

View file

@ -0,0 +1,44 @@
---
name: od-plugin-authoring
description: Guided scenario for creating an Open Design plugin folder that can be installed into My plugins.
od:
scenario: plugin-authoring
mode: scenario
---
# od-plugin-authoring (scenario)
Use this scenario when the user wants to create their own Open Design plugin.
## Required outcome
Produce a folder named `generated-plugin/` in the active project workspace.
At minimum, the folder must contain:
- `SKILL.md` with frontmatter and clear agent instructions.
- `open-design.json` with valid plugin metadata, `od.kind`, mode, task kind, capabilities, and inputs when needed.
Add `examples/`, `assets/`, or other supporting files only when they help the plugin be used or reviewed.
## Authoring rules
- Follow `docs/plugins-spec.md` and the schema at `docs/schemas/open-design.plugin.v1.json`.
- Treat `SKILL.md` as the canonical behavior description. `open-design.json` should describe how Open Design installs, applies, and presents that behavior.
- Keep the generated plugin local-user friendly: it should not require marketplace publishing, enterprise trust setup, or private team catalog configuration.
- Choose a stable plugin id from the user's requested workflow. Use lowercase letters, numbers, dashes, underscores, or dots.
- Include a short readiness summary when finished:
- Files created.
- Whether the folder is ready to add to My plugins.
- Any validation or follow-up needed before install.
## Suggested folder shape
```text
generated-plugin/
SKILL.md
open-design.json
examples/
assets/
```
The `examples/` and `assets/` directories are optional. Do not create empty directories just to match the sketch.

View file

@ -0,0 +1,96 @@
{
"$schema": "https://open-design.ai/schemas/plugin.v1.json",
"name": "od-plugin-authoring",
"title": "Plugin authoring",
"version": "0.1.0",
"description": "Guided scenario for creating a local Open Design plugin folder that can be added to My plugins.",
"license": "MIT",
"author": {
"name": "Open Design",
"url": "https://github.com/nexu-io"
},
"homepage": "https://github.com/nexu-io/open-design/tree/main/plugins/_official/scenarios/od-plugin-authoring",
"tags": [
"scenario",
"first-party",
"plugin-authoring",
"my-plugins"
],
"compat": {
"agentSkills": [
{
"path": "./SKILL.md"
}
]
},
"od": {
"kind": "scenario",
"taskKind": "new-generation",
"scenario": "plugin-authoring",
"mode": "scenario",
"context": {
"skills": [
{
"path": "./SKILL.md"
}
]
},
"pipeline": {
"stages": [
{
"id": "discovery",
"atoms": [
"discovery-question-form"
]
},
{
"id": "plan",
"atoms": [
"direction-picker",
"todo-write"
]
},
{
"id": "generate-plugin",
"atoms": [
"file-write",
"live-artifact"
]
},
{
"id": "review-plugin",
"atoms": [
"critique-theater"
],
"repeat": true,
"until": "critique.score>=4 || iterations>=3"
}
]
},
"capabilities": [
"prompt:inject"
],
"useCase": {
"query": {
"en": "Create an Open Design plugin for {{pluginGoal}}. Produce a folder named generated-plugin with SKILL.md, open-design.json, and any useful examples or assets. Follow docs/plugins-spec.md and finish with a readiness summary for adding it to My plugins.",
"zh-CN": "为 {{pluginGoal}} 创建一个 Open Design 插件。请生成名为 generated-plugin 的文件夹,包含 SKILL.md、open-design.json以及有用的 examples 或 assets。遵循 docs/plugins-spec.md并在最后总结它是否可以添加到 My plugins。"
},
"exampleOutputs": [
{
"path": "./examples/generated-plugin/open-design.json",
"title": "Generated plugin manifest"
}
]
},
"inputs": [
{
"name": "pluginGoal",
"type": "string",
"required": false,
"default": "a reusable workflow described by the user's prompt",
"label": "Plugin goal",
"placeholder": "turn a brand brief into a launch landing page workflow"
}
]
}
}

View file

@ -72,7 +72,7 @@ sequenceDiagram
## Proposed implementation units
- U1. **Home prefill and focus contract**
- [x] U1. **Home prefill and focus contract**
**Goal:** Add a reusable way to navigate to Home with a prefilled prompt and focus the textarea.
@ -93,7 +93,7 @@ sequenceDiagram
- Edge case: refreshing Home after the handoff does not repeatedly overwrite a user's edited prompt.
- Integration: submitting the prefilled prompt still creates a project through the existing `PluginLoopSubmit` path.
- U2. **Plugins create entry**
- [x] U2. **Plugins create entry**
**Goal:** Turn the Plugins tab `Create / Import` surface into two clear actions: guided create and import.
@ -112,7 +112,7 @@ sequenceDiagram
- Regression: GitHub/zip/folder import still installs and lands in `My plugins`.
- Accessibility: the create action has a clear button label and does not masquerade as a disabled tab.
- U3. **Plugin authoring scenario**
- [x] U3. **Plugin authoring scenario**
**Goal:** Give the agent a purpose-built plugin authoring context that asks for the right files and validation behavior.
@ -133,7 +133,7 @@ sequenceDiagram
- Integration: applying the scenario injects its local `SKILL.md` body into the run snapshot.
- Regression: regular `od-new-generation` and migration scenarios are unaffected.
- U4. **Generated folder visibility in project output**
- [x] U4. **Generated folder visibility in project output**
**Goal:** Let users inspect and select a generated plugin folder from the project workspace.
@ -155,7 +155,7 @@ sequenceDiagram
- Edge case: nested files remain selectable without losing their relative path.
- Error path: malformed or partial plugin folders do not show a misleading install success action.
- U5. **Install generated folder as My plugin**
- [x] U5. **Install generated folder as My plugin**
**Goal:** Add a one-click action that installs a daemon-visible generated folder into the user plugin registry.
@ -181,7 +181,7 @@ sequenceDiagram
- Security: `../` traversal or absolute paths outside the project root are rejected.
- Integration: after install, `GET /api/plugins` includes the new record with non-`bundled` source kind.
- U6. **Home rail create-plugin chip parity**
- [x] U6. **Home rail create-plugin chip parity**
**Goal:** Let the future Home rail entry use the same authoring flow without duplicating behavior.

View file

@ -58,8 +58,8 @@ plugin manifest (od.pipeline.stages[])
| A | shipped | Plugin-local SKILL.md reaches `## Active skill`; Home query auto-binds default scenario per kind. |
| B | shipped (MVP) | Chip rail mirrors NewProject taxonomy + adds Figma / folder / template shortcuts. Secondary chip rows (model picker for image, inline figmaUrl input) deferred. |
| C | shipped (MVP) | Bundled `od-media-generation` scenario for image/video/audio; uses existing `media-image` / `media-video` / `media-audio` atoms rather than a new wrapper atom. |
| D | pending | Real atom workers replacing the stub pipeline runner. |
| E | pending | Verification gate (e2e). |
| D | shipped (MVP) | `apps/daemon/src/plugins/atoms/registry.ts` + `built-ins.ts` introduce an atom worker registry; `firePipelineForRun` now drives stages through `runStageWithRegistry`. Set `OD_PIPELINE_RUNNER=stub` to fall back to the v1 canned-signal runner. Real workers only ship for `critique-theater` today (reads `run_devloop_iterations.critique_summary` for the latest parseable score); every other FIRST_PARTY_ATOM registers a permissive worker so happy-path convergence matches v1. |
| E | shipped (MVP) | `pnpm guard` + `pnpm typecheck` green; daemon plugin suites (atom-registry, pipeline-runner, local-skill, apply, bundled-scenarios, headless-run, bundled, pipeline, simulate) all green; web 720/720 green; full daemon suite 1847/1852 with the remaining 5 failures all reproduced on `HEAD` without these changes (3× `finalize-design` macOS-tmpdir symlink issue; 2× chat-route/origin-validation order-dependent flakes that pass in isolation). Cross-app browser e2e covering the bare-query / chip-click flows is deferred to a follow-up since the existing Playwright harness only ships packaged-app smoke. |
### Stage A — Plugin actually injects, Home never runs naked
@ -96,10 +96,16 @@ Exit criteria
### Stage D — Real stage / atom workers (replaces the stub)
- Introduce `apps/daemon/src/plugins/atoms/registry.ts` keyed by atom id → worker.
- Implement workers for atoms that are not yet wired (e.g. `file-write`, `live-artifact`, `discovery-question-form`, `direction-picker`, `todo-write`, `critique-theater`).
- Replace `firePipelineForRun` stub `runStage` with `runAtomById(stage, context)`.
- Strengthen `## Active stage` block so the agent has to acknowledge each atom's outputs.
As shipped (MVP):
- `apps/daemon/src/plugins/atoms/registry.ts` owns the atom worker registry: `registerAtomWorker`, `runStageWithRegistry`, and the frozen `PERMISSIVE_DEFAULT_SIGNALS` table. Real-worker outputs replace permissive defaults wholesale so a real score of 5 never gets clipped to 4; cross-worker conflicts inside a single stage still pessimistically merge (false-wins / lowest-number-wins).
- `apps/daemon/src/plugins/atoms/built-ins.ts` registers a worker for every `FIRST_PARTY_ATOMS` entry on first use. Only `critique-theater` ships a real watcher today — it reads `run_devloop_iterations.critique_summary` for the latest parseable `score=N` token. Every other atom registers a permissive worker that returns no signals (so the defaults flow through) — that keeps backwards-compat with the v1 stub while documenting the worker surface for future migrations.
- `apps/daemon/src/server.ts#firePipelineForRun` now branches on `OD_PIPELINE_RUNNER`: default (`registry`) drives stages through `runStageWithRegistry`; `OD_PIPELINE_RUNNER=stub` falls back to the canned signal pump for diagnostic bisection.
Deferred to a follow-up:
- Real watchers for `file-write`, `live-artifact`, `direction-picker`, `discovery-question-form`, `todo-write`, etc. These require either an agent-write protocol that touches DB rows the daemon can observe, or a side-channel (artifacts table / genui surface responses) wired into worker reads. The registry shape is ready; only the workers themselves remain.
- Strengthen `## Active stage` block so the agent has to acknowledge each atom's outputs. (Today the contracts-side `renderActiveStageBlock` already exists; we still need to gate stage progression on the agent's structured acknowledgement.)
### Stage E — Verification gate