Commit graph

125 commits

Author SHA1 Message Date
pftom
0edbf38171 feat(plugins): add specVersion and version fields to plugin and marketplace schemas
- Introduced `specVersion` and `version` fields to the plugin and marketplace schemas, ensuring better versioning and compatibility tracking.
- Updated various components and functions to handle the new fields, including database migrations, plugin snapshots, and marketplace management.
- Enhanced tests to validate the presence and correctness of the new fields in plugin manifests and marketplace entries.
- Improved documentation to reflect the changes in schema requirements and provide guidance on the new versioning system.

This update strengthens the plugin ecosystem by providing clear versioning, enhancing the reliability and maintainability of plugins and marketplaces.
2026-05-13 22:24:50 +08:00
pftom
8b2d48a258 feat(daemon, web): enhance plugin preview handling and add new templates
- Introduced logic to assemble example slides with a companion template when the declared entry is missing, improving the user experience for plugin previews.
- Updated the server logic to handle special cases for `example-slides.html`, ensuring proper fallback to `template.html` when applicable.
- Enhanced tests to verify the new preview assembly functionality and ensure correct rendering of fallback content.
- Added new HTML and Markdown examples for various skills, including a magazine article layout and a Twitter share card, expanding the available templates for users.

This update significantly improves the plugin preview experience, providing users with more robust and visually appealing fallback options.
2026-05-13 20:58:24 +08:00
pftom
9e196d34af feat(daemon, web): enhance plugin sharing workflows and UI components
- Updated the plugin sharing prompts to utilize local daemon endpoints for publishing to GitHub and contributing to Open Design, streamlining the user experience.
- Refactored the `PluginsView` and `PluginShareMenu` components to support new sharing functionalities, including confirmation modals and improved link handling.
- Enhanced the CSS styles for the plugin share confirmation modal and related UI elements for better visual consistency.
- Added tests to verify the functionality of the new sharing workflows and ensure proper integration within the existing plugin management system.

This update significantly improves the plugin sharing experience, making it easier for users to publish and contribute their plugins effectively.
2026-05-13 14:35:09 +08:00
pftom
c36609c47d feat(daemon, web): implement plugin sharing project creation and enhance CLI functionality
- Added new flags for conversation, message, agent, and model in the CLI to support enhanced plugin sharing features.
- Introduced a new API endpoint for creating share projects for plugins, allowing users to publish to GitHub or contribute to Open Design.
- Updated the UI components to facilitate the new sharing functionalities, including prompts for user input during the sharing process.
- Enhanced the project management system to handle new plugin share actions, improving user interaction and experience.
- Added tests to ensure the reliability of the new sharing features and their integration within the existing plugin management system.

This update significantly enhances the plugin ecosystem by enabling users to share their creations more effectively and streamline collaboration.
2026-05-13 07:01:12 +08:00
pftom
5f71968f61 feat(daemon, web): implement plugin sharing features for GitHub and Open Design contributions
- Added new API endpoints for publishing plugins to GitHub and contributing to Open Design, enhancing the plugin sharing capabilities.
- Introduced functions for handling plugin sharing actions, including `publishGeneratedPluginToGitHub` and `contributeGeneratedPluginToOpenDesign`.
- Updated the `DesignFilesPanel` and `FileWorkspace` components to support new sharing functionalities, allowing users to publish or contribute plugins directly from the interface.
- Enhanced the UI with new buttons for publishing and contributing plugins, improving user interaction and experience.
- Added tests to ensure the reliability of the new sharing features and their integration within the existing plugin management system.

This update significantly improves the plugin ecosystem by enabling users to share their creations with the community and streamline collaboration.
2026-05-12 22:39:32 +08:00
pftom
6f818d971d 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.
2026-05-12 21:38:45 +08:00
pftom
ed2cbe171b feat(daemon, web): implement media generation scenario and enhance plugin handling
- Introduced a new `od-media-generation` scenario plugin for handling image, video, and audio projects, providing a default pipeline for media generation.
- Updated the `collectBundledScenarios` function to deduplicate scenarios and prefer canonical IDs for task kinds, improving plugin routing.
- Enhanced the `PluginsView` and `HomeHero` components to better display community and user-installed plugins, improving user experience.
- Refactored tests to accommodate the new media generation scenario and ensure proper functionality across plugin types.

This update significantly enhances the media handling capabilities and overall plugin management experience, making it easier for users to work with various media projects.
2026-05-12 20:54:33 +08:00
pftom
13d5598b0c feat(web, daemon): enhance plugin import functionality and UI components
- Added support for uploading plugins via zip files and folders, improving the plugin import process.
- Introduced a new `PluginImportModal` for a streamlined user experience when importing plugins.
- Updated the `PluginsView` to include disabled states for unfinished plugin areas, enhancing clarity for users.
- Refactored various components to utilize the new `resolvePluginQueryFallback` function for improved localization handling.
- Enhanced CSS styles for better visual feedback and responsiveness in the plugin import interface.

This update significantly improves the plugin management experience, making it easier for users to import and manage plugins effectively.
2026-05-12 20:46:17 +08:00
pftom
443aea72c5 feat(daemon, web): enhance plugin handling and UI integration
- Introduced a new plugin upload mechanism with file size limits and memory storage, allowing users to upload plugins directly.
- Implemented fallback logic for plugin application, ensuring projects can be created without explicit plugin requests.
- Enhanced the UI to support plugin selection and integration, including a new `PluginsView` component for managing plugins.
- Updated various components to utilize localized text for plugin queries, improving user experience across different languages.
- Added tests for new plugin functionalities and local skill loading, ensuring reliability and correctness.

This update significantly improves the plugin management experience, providing users with better tools for plugin integration and interaction.
2026-05-12 20:42:40 +08:00
pftom
244e8b7981 feat(daemon): enhance plugin preview handling and add fallback mechanisms
- Implemented a new `collectPluginPreviewCandidates` function to gather potential HTML assets for plugins, improving the robustness of the preview endpoint.
- Introduced `discoverPluginHtmlAssets` to scan common directories for HTML files, ensuring that plugins with missing declared entries can still provide a valid preview.
- Updated the `/api/plugins/:id/preview` route to utilize the new candidate collection and discovery functions, enhancing the user experience by preventing blank tiles in the gallery.
- Added comprehensive tests for the new fallback logic to ensure reliability and correctness in various scenarios.

This update significantly improves the plugin preview functionality, ensuring users have access to valid previews even when manifest entries are outdated or missing.
2026-05-12 16:45:24 +08:00
pftom
1bdf765cf2 feat(daemon): enrich API responses with surface specs and add new flags
- Implemented `--schema` flag for `od ui show` to return only the JSON Schema of the surface.
- Enhanced the response of `GET /api/runs/:runId/genui/:surfaceId` to include the surface spec from the AppliedPluginSnapshot.
- Introduced new flags for daemon and library commands to improve command handling and parsing.
- Added tests for the new functionality, ensuring proper behavior of the enriched responses and flag handling.

This change supports headless interactions by allowing code agents to inspect surface contracts before responding.
2026-05-11 20:27:05 +08:00
Cursor Agent
2a24043c32
feat(plugins): od plugin events purge admin escape hatch (Phase 4)
Plan NN1.

apps/daemon/src/plugins/events.ts ships a public buffer reset
distinct from the test-only `__resetPluginEventBufferForTests`:

  purgePluginEventBuffer() \u2192 PurgePluginEventBufferResult
    {
      purged:    <count discarded>,
      firstId:   <id of first discarded entry, or null>,
      lastId:    <id of last discarded entry, or null>,
      preNextId: <buffer's nextId pre-purge, for audit>,
    }

The pre-purge stats let an operator confirm what they discarded;
preNextId surfaces 'did we lose a window of events between an
external export and the purge'.

apps/daemon/src/server.ts: new loopback-only POST
/api/plugins/events/purge route (requireLocalDaemonRequest).

CLI: `od plugin events purge --confirm [--json]`. Refuses to
run without --confirm so a stray invocation never drops audit
data accidentally. Output:
  [events purge] dropped 47 events (id range: 1 \u2192 47; preNextId=48)

Daemon tests: 1818 \u2192 1822 (+4 cases on plugins-events-purge:
zero-shape on empty buffer, full-buffer purge resets state +
records id range, post-purge nextId restarts at 1, subscribers
are cleared on purge — a new subscriber starts fresh).

Co-authored-by: Tom Huang <1043269994@qq.com>
2026-05-09 18:31:58 +00:00
Cursor Agent
20ef3f52b3
feat(daemon): od daemon db verify SQLite integrity check (Phase 5)
Plan LL1.

apps/daemon/src/storage/db-inspect.ts gains a pure helper:

  verifySqliteIntegrity({ db, quick? })
    \u2192 DbIntegrityReport { ok, mode, issues[], elapsedMs, generatedAt }

Wraps two SQLite PRAGMAs:
  integrity_check (or quick_check when quick=true) \u2014 verifies
    each table + index page is internally consistent; quick skips
    the index-content check for ~10x speedup on big DBs.
  foreign_key_check \u2014 walks every FK and reports rows that
    reference a missing parent. Only meaningful when foreign_keys
    PRAGMA is enabled (which the daemon does in openDatabase).

Issues come back tagged kind='integrity' | 'foreign_key' so a
consumer can route alerts differently.

apps/daemon/src/server.ts: new POST /api/daemon/db/verify
(loopback-only via requireLocalDaemonRequest) accepts ?quick=1.

CLI: `od daemon db verify [--quick] [--json]`. Exit 0 on
ok=true, 4 on any issue. Operator one-liner:
  od daemon db verify --quick
  \u2192 [db verify] mode=quick_check  ok=true  issues=0  3ms

Daemon tests: 1808 \u2192 1813 (+5 cases on storage-db-verify:
healthy fresh DB ok, --quick mode tag, FK violation detection
(via foreign_keys=OFF + insert + foreign_keys=ON dance),
elapsedMs / generatedAt, populated DB still ok).

Co-authored-by: Tom Huang <1043269994@qq.com>
2026-05-09 18:16:41 +00:00
Cursor Agent
a43f34f00c
feat(plugins): od plugin events snapshot/stats + tail filters (Phase 4)
Plan KK1 + KK2 + KK3.

KK1: `od plugin events snapshot` non-SSE one-shot read.
  New GET /api/plugins/events/snapshot returns
  { events, count, generatedAt }. Supports the same ?since=<id>
  trim as the SSE route. Useful for dashboards that don't want
  to hold a long-lived connection.

KK2: `od plugin events stats` rollup helper.
  New summarisePluginEvents(events) pure helper +
  GET /api/plugins/events/stats route. Counts by kind +
  byPluginId (skipping empty plugin ids from marketplace
  events) + oldest/newest timestamps + id range. CLI
  pretty-prints the rollup with sorted-key counts so output
  is byte-deterministic.

KK3: --kind / --plugin-id filter flags on tail / snapshot.
  Both subcommands accept the same filter knobs. tail filters
  client-side post-render (so the SSE backlog still arrives
  but the renderer drops non-matching entries); snapshot does
  the same client-side filter on the JSON response. Lets ops
  write 'show me only plugin.trust-changed for slack-bot' as
  a one-liner.

CLI summary: helpers in cli.ts now reuse formatCounts /
formatTimestamp from §3.GG1 for deterministic output across
status-style commands.

Daemon tests: 1803 \u2192 1808 (+5 cases on plugins-events-stats:
zero-shape on empty buffer, byKind aggregation, byPluginId
skipping empty ids, oldest+newest+id-range, roll-up over a
pre-filtered slice respects the input order).

Co-authored-by: Tom Huang <1043269994@qq.com>
2026-05-09 18:10:50 +00:00
Cursor Agent
c9669339de
feat(plugins): more event producer hooks (Phase 4)
Plan JJ1 — extends §3.II1 ring buffer with three new producer
hooks + distinguishes upgrade-vs-install in the install path.

  installPlugin opts gain `eventKind?: 'installed' | 'upgraded'`
    Default 'installed' (back-compat). The upgrade route now
    passes 'upgraded' so the live tail shows
    'plugin.upgraded' instead of 'plugin.installed' when the
    operation came through POST /api/plugins/:id/upgrade.

  POST /api/plugins/:id/trust now emits 'plugin.trust-changed'
    with { action, capabilities, total } so security audits can
    track grant / revoke operations from the live tail.

  POST /api/applied-plugins/prune now emits 'plugin.snapshot-pruned'
    when result.removed > 0, with { removed, before? } so ops can
    track GC churn across daemon uptime.

  POST /api/marketplaces/:id/refresh now emits
    'plugin.marketplace-refreshed' with { marketplaceId } so the
    catalog refresh cadence is visible.

Each producer hook is wrapped in a try/catch and never blocks the
underlying mutation if the event ring buffer ever throws.

Daemon tests: 1797 \u2192 1803 (+6 cases on plugins-events-producers:
installFromLocalFolder emits plugin.installed, installPlugin with
eventKind='upgraded' emits plugin.upgraded instead, default
back-compat eventKind, uninstallPlugin emits plugin.uninstalled,
uninstall event guard pins the (removed || removedFolder)
predicate, install \u2192 upgrade sequence shows distinct kinds in
order).

Co-authored-by: Tom Huang <1043269994@qq.com>
2026-05-09 18:04:56 +00:00
Cursor Agent
16ac3b549d
feat(plugins): plugin event ring buffer + SSE tail (Phase 4)
Plan II1.

apps/daemon/src/plugins/events.ts ships a small in-memory FIFO
ring buffer for plugin lifecycle events. Producers call
recordPluginEvent({ kind, pluginId, details? }); consumers either
pull pluginEventSnapshot(since?) for a one-shot read or call
subscribePluginEvents(cb) for a live feed (returns an unsubscribe
callback).

The buffer is capped at 1000 entries — older events fall off the
head. State resets on daemon restart (events survive the run,
not the boot).

PluginEventKind union covers the lifecycle vocabulary:
  plugin.installed / .upgraded / .uninstalled
  plugin.trust-changed / .applied
  plugin.snapshot-pruned / .marketplace-refreshed

Producer hooks:
  installer.installFromLocalFolder() emits 'plugin.installed' on
    success with { version, sourceKind, source, trust, warnings }.
  installer.uninstallPlugin() emits 'plugin.uninstalled' on
    successful row removal.

(Apply / snapshot-prune / trust-changed hooks are wiring in
incrementally in subsequent slices; the producer surface is
documented in events.ts.)

apps/daemon/src/server.ts: new GET /api/plugins/events SSE route.
On open: emits the buffered backlog as 'event: backlog' entries,
then forwards every newly-recorded event as 'event: plugin' with
the same shape. Optional ?since=<id> trims the backlog.

CLI: `od plugin events tail [-f] [--since <id>] [--json]`.
Default mode drains the backlog + idles 200ms before exiting;
-f / --follow keeps the connection open and prints live events.
JSON mode emits one event per line for easy piping into jq.

Daemon tests: 1787 \u2192 1797 (+10 cases on plugins-events-buffer:
monotonic id + epoch ms, multi-record id sequence, optional
details field, full snapshot vs. since filter, snapshot copy
semantics, subscriber forwarding, unsubscribe stops forwarding,
listener-exception isolation, 1000-cap eviction).

Co-authored-by: Tom Huang <1043269994@qq.com>
2026-05-09 17:55:39 +00:00
Cursor Agent
b3e8246f85
feat(plugins,daemon): doctor --strict + db vacuum (Phase 4 + 5)
Plan HH1 + HH2.

HH1: `od plugin doctor --strict` flag promotes doctor warnings
to errors. Useful in CI when any drift in resolved-context refs
or atom warnings should fail the build. JSON output includes
{ strict, passed }; exit code 4 distinguishes 'strict failed' from
'doctor errors' (exit 1). Verify config grows a `strict: true`
knob that propagates through verifyPlugin() — committing
.od-verify.json with strict:true gives plugins a one-line CI
'no warnings allowed' policy.

HH2: `od daemon db vacuum`. New POST /api/daemon/db/vacuum route
runs SQLite VACUUM, reports before/after sizes + reclaimed bytes
+ elapsed ms. Useful after large delete batches (snapshot prune,
plugin uninstall) shrink rows but leave space allocated to the
file. Loopback-only via requireLocalDaemonRequest (matches the
existing applied-plugins/export route shape — DB compaction is
operator-only).

CLI:
  od plugin doctor <id> --strict
  od daemon db vacuum

Daemon tests: 1784 \u2192 1787 (+3 cases on plugins-verify
strict-mode: default-pass on warnings, strict-fail on warnings,
strict-still-pass on zero issues). Existing plugins-verify
warnings-pass test stays correct because warnings-only doctor
keeps doctor.ok=true (the strict path explicitly opts in).
Existing plugins-canon / plugins-doctor tests stay byte-equal.

Co-authored-by: Tom Huang <1043269994@qq.com>
2026-05-09 17:47:28 +00:00
Cursor Agent
d2ce5325f8
feat(daemon): od daemon db status SQLite inventory (Phase 5)
Plan GG1.

apps/daemon/src/storage/db-inspect.ts ships a pure helper:

  inspectSqliteDatabase({ db, file }) \u2192 DaemonDbStatusReport
    {
      kind:           'sqlite',
      location:       <abs path>,
      sizeBytes:      <primary + WAL + SHM>,
      schemaVersion:  <user_version PRAGMA>,
      tables:         [{ name, rowCount }, ...],
      generatedAt:    <epoch ms>,
    }

User tables only (sqlite_* / better_sqlite3_* excluded). Tables
walk in lexicographic order so the report is byte-deterministic.
Each table's row count is computed via a parameterised query
behind an identifier sanitiser ([A-Za-z_][A-Za-z0-9_]*) that
rejects malformed names; a corrupted view doesn't crash the
whole inspection — its row count just falls back to 0.

apps/daemon/src/server.ts: new GET /api/daemon/db wires the
inspector against the live DB handle + RUNTIME_DATA_DIR-relative
file path.

CLI: `od daemon db status [--json]`. Pretty-prints two columns
(table name padded to longest; row count). Helps ops sanity-check
deployments + compare expected-vs-actual table rosters across
daemon upgrades.

Daemon tests: 1776 \u2192 1784 (+8 cases on storage-db-inspect:
kind+location reporting, schemaVersion from PRAGMA, fresh DB
defaults to 0, system tables excluded, lexicographic ordering,
WAL companion size summed, generatedAt timestamp, empty DB
non-crash).

Co-authored-by: Tom Huang <1043269994@qq.com>
2026-05-09 17:39:20 +00:00
Cursor Agent
85729b8000
feat(plugins): od plugin stats inventory health report (Phase 4)
Plan DD1.

apps/daemon/src/plugins/stats.ts ships two pure helpers:

  pluginInventoryStats(plugins) \u2192 PluginInventoryStats
    { total, bySourceKind, byTrust, byTaskKind,
      withElevatedCapabilities, bundled, thirdParty,
      lastInstalledAt, lastUpdatedAt }

  snapshotInventoryStats(rows) \u2192 SnapshotInventoryStats
    { total, byStatus, withProject, withRun,
      oldestAppliedAt, newestAppliedAt }

Elevated-capability detection covers fs:write, subprocess, bash,
network, and any 'connector:*' grant — the §5.3 set that lets a
plugin mutate state outside the project cwd.

apps/daemon/src/server.ts: new GET /api/plugins/stats route reads
installed_plugins + applied_plugin_snapshots and returns
{ plugins, snapshots, generatedAt } (snapshot read uses raw SQL
since this is the only place outside snapshots.ts that needs the
status / linkage flat).

CLI: `od plugin stats [--json]`. Pretty-print mode shows two
sections (Plugins / Snapshots) with sorted-key counts so the
output is byte-deterministic. printPluginHelp() updated.

Daemon tests: 1744 \u2192 1753 (+9 cases on plugins-stats:
empty-roster zero-shape, sourceKind / trust / taskKind counts,
elevated-capability detection (excludes prompt:inject; counts
fs:write / network / connector:* / subprocess / bash),
bundled vs. third-party split, newest installedAt / updatedAt
extraction; snapshot zero-shape + status / project / run /
oldest+newest applied tally).

Co-authored-by: Tom Huang <1043269994@qq.com>
2026-05-09 17:15:20 +00:00
Cursor Agent
501fdf9e3a
feat(plugins): od plugin canon <snapshotId> show prompt block (Phase 4)
Plan CC1.

apps/daemon/src/server.ts: new `GET /api/applied-plugins/:snapshotId/canon`
returns the canonical `## Active plugin` block this snapshot
splices into the system prompt. Two response modes:

  default            \u2192 { snapshotId, pluginId, block }
  Accept: text/plain \u2192 raw block body for shell pipes

Powered by the same renderPluginBlock() composeSystemPrompt() uses,
so the route output is byte-equal to what the agent reads. Useful
for:

  - debugging 'why is the agent ignoring my plugin' (read what's
    actually injected),
  - locking byte-equality regression fixtures against the daemon's
    renderPluginBlock() output,
  - eyeballing what the prompt block looks like before applying.

CLI: `od plugin canon <snapshotId> [--json]`. Default output is
plain text suitable for piping; --json wraps the block in
{ snapshotId, pluginId, block }. printPluginHelp() updated.

Daemon tests: 1737 \u2192 1744 (+7 cases on plugins-canon: id +
version when title absent, pluginTitle wins when present, plugin
description appended, sorted alphabetic inputs block, byte-equal
output across calls with same snapshot (replay invariance check),
inputs block omitted when no inputs, query echoed as stylized
brief).

Co-authored-by: Tom Huang <1043269994@qq.com>
2026-05-09 17:10:04 +00:00
Cursor Agent
f68d0148fd
feat(plugins): od atoms info <id> + Phase 6/7/8 catalog promotion
Plan AA2.

Two slices:

1. Catalog drift fix. apps/daemon/src/plugins/atoms.ts had
   nine atoms still tagged status='planned' even though their
   daemon impls landed across §3.N1-N4 / §3.O2-O5 / §3.P1-P2 /
   §3.Q2 / §3.S1. Promotes them to 'implemented' + adds the
   missing 'build-test' entry that was shipped without a
   matching catalog row. Net result: every atom in the catalog
   is now status='implemented'.

   Updated entries:
     code-import     planned \u2192 implemented
     design-extract  planned \u2192 implemented
     figma-extract   planned \u2192 implemented
     token-map       planned \u2192 implemented
     rewrite-plan    planned \u2192 implemented
     patch-edit      planned \u2192 implemented
     diff-review     planned \u2192 implemented
     handoff         planned \u2192 implemented
     build-test      (new)   \u2192 implemented

2. `od atoms info <id>` CLI + matching daemon route. New
   GET /api/atoms/:id returns the catalog row plus the bundled
   SKILL.md body (when one exists at
   plugins/_official/atoms/<id>/SKILL.md), so a user can read
   what the atom does + the prompt fragment that drives it from
   one CLI invocation.

   CLI behaviour:
     od atoms info code-import           # human-formatted
     od atoms info code-import --json    # raw JSON

Daemon tests: 1716 \u2192 1728 (+12 cases on plugins-atoms-info:
9 atoms confirmed promoted to 'implemented', build-test
catalog presence + matching task-kind, zero remaining 'planned'
atoms invariant, every atom has a non-empty taskKinds[]).

Co-authored-by: Tom Huang <1043269994@qq.com>
2026-05-09 16:53:09 +00:00
Cursor Agent
bae57da565
feat(plugins): od plugin upgrade <id> re-install from recorded source (Phase 4)
Plan Z2.

apps/daemon/src/server.ts: new `POST /api/plugins/:id/upgrade`
route that streams the same SSE shape as
`POST /api/plugins/install`. Internally:

  1. Loads the InstalledPluginRecord by id.
  2. Rejects with 409 / code='bundled-plugin' when
     sourceKind='bundled' (bundled plugins ship with the daemon
     image and the bundled boot walker re-registers them every
     boot — letting an operator 'upgrade' would silently overwrite
     the daemon's authoritative copy).
  3. Rejects with 409 / code='missing-source' when the recorded
     source string is empty (manual SQLite tampering escape hatch).
  4. Otherwise re-runs installPlugin() against the recorded
     `plugin.source`. installPlugin() already routes by source
     prefix (github: / https://*.tar.gz / local folder), so the
     upgrade replays whichever byte path the original install
     used.

CLI: `od plugin upgrade <id>`.
  - Same SSE consumer as install, with [upgrade] prefixed lines.
  - Exit 0 on success, 1 on installer error, 2 on usage error.

printPluginHelp() updated.

Daemon tests: 1702 \u2192 1705 (+3 cases on plugins-upgrade:
re-install bumps the registry version, re-running with the same
source on disk is idempotent, on-disk manifest stays in sync with
the SQLite row across an upgrade).

Co-authored-by: Tom Huang <1043269994@qq.com>
2026-05-09 16:43:14 +00:00
Cursor Agent
b7f0fc0d96
feat(daemon): flip OD_BUNDLED_ATOM_PROMPTS default to ON (Phase 4)
Plan V1 / spec §23.4.

The bundled SKILL.md fragment library now covers every Phase 6/7/8
atom impl shipped this changeset (build-test, code-import,
design-extract, figma-extract, token-map, rewrite-plan, patch-edit,
diff-review, handoff). The §3.M3-M5 audit-block on flipping the
default-active flag is therefore lifted.

The new policy:

  default:                      ON   (activeStageBlocks render)
  OD_BUNDLED_ATOM_PROMPTS unset ON
  OD_BUNDLED_ATOM_PROMPTS=''    ON
  OD_BUNDLED_ATOM_PROMPTS=1     ON
  OD_BUNDLED_ATOM_PROMPTS=0     OFF  (explicit opt-out for
                                      snapshot replay against
                                      pre-§3.V1 daemons + regression
                                      bisects that need byte-equal
                                      pre-flip prompts)
  OD_BUNDLED_ATOM_PROMPTS=*     ON   (forward-compat)

Effect: a run with a plugin snapshot whose pipeline carries any
of the bundled atoms now ALWAYS gets the matching SKILL.md fragments
spliced in as 'Active stage' blocks ahead of the rest of the prompt,
without operators having to set the env var. Runs without a
snapshot keep byte-equal prompts (the snapshotId guard short-
circuits the build).

Daemon tests: 1661 \u2192 1666 (+5 cases on
plugins-bundled-atom-prompts-default: ON when unset / empty / '1'
/ unknown values; OFF only when explicit '0').

Co-authored-by: Tom Huang <1043269994@qq.com>
2026-05-09 16:15:31 +00:00
Cursor Agent
a1bfac34f6
feat(plugins): wire diff-review GenUI response \u2192 review/decision.json (Phase 8)
Plan R1 / spec §10.3 / §21.5.

When the user (or agent) responds to the auto-derived
`__auto_diff_review_<stageId>` choice surface, the daemon now
immediately persists the decision into the run's project cwd as
`<cwd>/review/decision.json` so the next pipeline stage (handoff,
typically) sees the user's choice without an extra agent turn.

apps/daemon/src/plugins/atoms/diff-review-genui-bridge.ts

  - isDiffReviewSurfaceId(id)            — owns the
    '__auto_diff_review_' prefix; future renames flow from one
    constant.
  - parseDiffReviewGenuiResponse(value)  — strict JSON validator
    coercing the surface payload into runDiffReview's
    { decision, accepted_files?, rejected_files?, reason? }
    shape. Rejects non-object payloads + unknown decisions.
  - applyDiffReviewDecisionToCwd({ cwd, value, reviewer })
    — end-to-end glue: parses, calls runDiffReview(), returns
    structured ok/error so the caller can decide what to surface.

apps/daemon/src/server.ts POST /api/runs/:runId/genui/:surfaceId/respond
now becomes async and, when isDiffReviewSurfaceId() matches, looks
up the run's projectId via design.runs.get(), resolves the project
cwd via resolveProjectDir(), and calls
applyDiffReviewDecisionToCwd() with reviewer='user' (or 'agent' /
'auto' when respondedBy says so).

Best-effort wiring:
  - Failures are caught and surfaced on the response payload as
    `diffReviewBridge: { ok: false, error }` so the agent can
    retry without the GenUI respond contract regressing.
  - No-project runs return diffReviewBridge: { ok: false, error:
    'run is not linked to a project' }.

Daemon tests: 1618 → 1629 (+11 cases on
plugins-diff-review-genui-bridge: prefix detection, payload
parsing happy + sad paths (non-object, malformed decision,
non-string file lists, optional reason forwarding), end-to-end
applyDiffReviewDecisionToCwd writes review/decision.json on
accept, agent reviewer + reason forwarding, partial-missing-file
rejection, malformed value does NOT touch disk).

Co-authored-by: Tom Huang <1043269994@qq.com>
2026-05-09 15:48:21 +00:00
Cursor Agent
694289295a
feat(plugins): wire bundled-scenario pipeline fallback through apply (spec §23.3.3)
Plan O1 / spec §23.3.3.

When a plugin omits `od.pipeline`, applyPlugin() now consults the
bundled scenario plugins registered by the daemon's bundled boot
walker (apps/daemon/src/plugins/bundled.ts) and copies the matching
scenario's pipeline verbatim into the snapshot. Match key:
`od.taskKind` (defaulting to 'new-generation' when absent).

Pure-side (packages/plugin-runtime):

  - new resolveAppliedPipeline({ manifest, scenarios }) →
      { pipeline, source: 'declared' | 'scenario' | 'none', scenarioId? }
  - RegistryView gains optional `scenarios?: ScenarioRegistryEntry[]`
    so the daemon registry view propagates the lookup table without
    coupling the pure resolver to SQLite.

Daemon-side (apps/daemon):

  - applyPlugin() calls resolveAppliedPipeline and stores the result on
    BOTH `ApplyResult.pipeline` and `AppliedPluginSnapshot.pipeline`
    so a replay reproduces the same stages without re-consulting the
    scenario registry.
  - server.loadPluginRegistryView() now collects bundled scenarios
    from `installed_plugins` (source_kind='bundled', od.kind='scenario',
    non-empty pipeline). Third-party scenarios are intentionally NOT
    eligible — only rows seeded by the bundled boot walker.

Invariants preserved:
  - Apply stays pure: scenarios are read once at the registry-view
    construction site and passed in; apply never opens a DB itself.
  - Scenario plugins themselves never fall back to themselves
    (resolveAppliedPipeline returns 'none' when od.kind='scenario').
  - manifestSourceDigest is unaffected by the fallback (digest is over
    manifest + inputs + resolvedContextRefs); replay invariance held.

Tests: +15 (plugin-runtime: 7 cases on resolveAppliedPipeline;
daemon: 8 cases on plugins-scenario-fallback covering declared
pipeline win, scenario fallback by taskKind, default to
new-generation, scenario plugins don't recurse, no scenarios →
pipeline=undefined, manifestSourceDigest stability across applies,
DB collector round-trip).

Co-authored-by: Tom Huang <1043269994@qq.com>
2026-05-09 15:05:54 +00:00
Cursor Agent
d491b3b7da
feat(plugins): wire activeStageBlocks under OD_BUNDLED_ATOM_PROMPTS=1
Plan M2 / spec §23.4 / §23.3.2 patch 2.

Closes the runtime half of the §23.3.2 lift. When
OD_BUNDLED_ATOM_PROMPTS=1 is set AND the run carries an
appliedPluginSnapshotId whose snapshot has a non-empty
od.pipeline.stages[*], composeDaemonSystemPrompt now:

  1. Fetches the snapshot from applied_plugin_snapshots.
  2. For each stage, calls loadAtomBodies(db, stage.atoms ?? []).
  3. Calls renderActiveStageBlock({ stageId, bodies }) for each
     stage; skips blocks that come back empty (no bundled atom
     resolved).
  4. Threads the resulting array as composeSystemPrompt's
     activeStageBlocks, which the composer splices in immediately
     after the  block.

Default behaviour (flag unset OR no snapshot OR no pipeline) is
byte-equal to today's prompt. Errors during atom-body loading
surface as a single console.warn and never block the run; the
composer falls through to the inline DISCOVERY_AND_PHILOSOPHY
constants.

The run's appliedPluginSnapshotId is forwarded from the in-memory
run object set up by POST /api/runs (plan §3.A1). Non-plugin runs
pass undefined and skip the entire branch.

Daemon tests stay at 1519/1519 (the new branch is opt-in; existing
tests don't set the env flag, so the default path is exercised
unchanged. The activeStageBlocks composer field already has 3
unit tests from §3.L2.)

Co-authored-by: Tom Huang <1043269994@qq.com>
2026-05-09 14:12:04 +00:00
Cursor Agent
61931faef9
feat(plugins): plugin-bundled component surface (sandboxed iframe)
Plan L3 / spec §10.3.5 / §9.2.

Web: GenUISurfaceRenderer now renders a sandboxed iframe when the
surface declares a component path. Communication is one-way: the
iframe posts `{ kind: 'genui:respond', surfaceId, value }` envelopes
back to the parent via window.postMessage; the parent forwards to
onAnswered. The iframe carries `sandbox="allow-scripts"` only — no
allow-same-origin / forms / popups / downloads, exactly as spec §9.2
mandates.

PendingSurface gains `componentPluginId`, supplied by the host (from
the run's AppliedPluginSnapshot.pluginId). Surfaces missing that
field render an inline error so a misconfigured host fails loudly.

Daemon: GET /api/plugins/:id/asset/* serves arbitrary files inside an
installed plugin's fsPath under the §9.2 preview CSP:

  Content-Security-Policy: default-src 'none'; img-src 'self' data: blob:;
                           media-src 'self' data: blob:;
                           style-src 'self' 'unsafe-inline';
                           script-src 'self' 'unsafe-inline';
                           connect-src 'none'; frame-ancestors 'self'
  X-Content-Type-Options: nosniff

Three guards: unknown plugin id → 404, traversal segments / absolute
paths → 400, escape-via-resolved-path → 400. Content-Type maps
common asset extensions; everything else falls back to
application/octet-stream so the browser's nosniff respects it.

Daemon tests: 1499 → 1503 (+4 cases on plugins-asset-route covering
404 / traversal-rejection / 200 with CSP headers / asset-not-found).
Web typecheck clean; jsdom tests stay at 586/586 (the iframe path is
DOM-shape-locked and has no callable surface to drive without a
real browser).

Co-authored-by: Tom Huang <1043269994@qq.com>
2026-05-09 13:55:04 +00:00
Cursor Agent
bef9add981
feat(plugins): bound-API-token guard + bearer middleware (Phase 5)
Plan K1 / spec §15.7 / §16 Phase 5.

The daemon now ships a two-part hosted-mode safety floor:

1. startServer() refuses to start when OD_BIND_HOST is set to a
   non-loopback address and OD_API_TOKEN is unset. Loopback hosts
   (127.0.0.1 / ::1 / localhost) keep the existing zero-config
   desktop / dev experience. The error message points the operator
   at `openssl rand -hex 32` so the next attempt succeeds in one
   step.

2. When OD_API_TOKEN is set, every /api/* request must carry an
   `Authorization: Bearer <OD_API_TOKEN>` header. Three escape
   hatches:
     - Loopback peers (req.socket.remoteAddress matches
       isLoopbackPeerAddress) skip the check — the desktop UI / local
       CLI never need a bearer.
     - The /api/health, /api/version, and /api/daemon/status probes
       remain open so monitoring + cloud orchestrators (k8s readiness,
       Compose healthcheck) work without touching secrets.
     - Mismatched / missing headers return 401 API_TOKEN_REQUIRED.

This closes a previously documented Phase 5 hazard: an operator
spinning up the daemon with OD_BIND_HOST=0.0.0.0 (the docker-compose
default) without a token would have published an unsecured API.

Daemon tests: 1486 → 1490 (+4 cases on api-token-guard:
non-loopback bind without token rejected, public host with token
boots, loopback callers still pass without bearer, probes stay open).

Co-authored-by: Tom Huang <1043269994@qq.com>
2026-05-09 13:29:31 +00:00
Cursor Agent
0631f04a00
feat(plugins): @open-design/agui-adapter package + GET /api/runs/:id/agui
Plan J1 + J2 / spec §10.3.5 / Phase 4.

New workspace package: packages/agui-adapter/. Pure-TS
bidirectional bridge between OD's native PersistedAgentEvent /
GenUIEvent / PluginPipelineStageEvent union and the AG-UI canonical
event protocol (https://github.com/CopilotKit/CopilotKit).

  - src/types.ts        — AGUIEvent discriminated union (agent.message,
                          tool_call, state_update, ui.surface_requested,
                          ui.surface_responded, run.lifecycle).
  - src/encode.ts       — encodeOdEventForAgui(event, ctx): maps every
                          OD native event onto the canonical shape; drops
                          events the encoder can't translate so external
                          AG-UI clients always see a clean stream.
  - tests/encode.test.ts (9 cases) covers message_chunk, tool_call,
                          run_started, end → started/completed/failed/
                          cancelled, pipeline_stage_started/completed,
                          genui_surface_request/response/timeout,
                          genui_state_synced, and the unknown-event drop.

apps/daemon/src/server.ts mounts GET /api/runs/:id/agui:

  - 404 for unknown run ids.
  - Replays the run's recorded events through the encoder on subscribe
    (so a reconnecting client with Last-Event-ID picks up exactly the
    AG-UI events it missed).
  - Subscribes to future events via a thin adapter client wrapper that
    routes through the existing run.clients fan-out, so the encoder
    runs lazily on each broadcast (no double event buffering).

Daemon depends on @open-design/agui-adapter; the package builds clean
and ships pure ESM. v1 plugins consume CopilotKit / agent-protocol
clients without modification — the adapter ships independently from
daemon main, so upstream protocol revs do not couple to the daemon
release cadence (per spec §10.3.5 Phase 4 contract).

Tests: agui-adapter 9/9, daemon 1481 → 1482 (+1 case on agui-route).

Co-authored-by: Tom Huang <1043269994@qq.com>
2026-05-09 13:11:48 +00:00
Cursor Agent
2848327199
feat(plugins): bundled atom plugins + boot walker (Phase 4 / spec §23)
Plan I3 / spec §23.3.5 + §23.3.4.

Lays the entry slice for spec §23 self-bootstrapping: the daemon's
own behavioural content moves out of system.ts hard-coded constants
and into per-atom SKILL.md files under plugins/_official/atoms/.

New ground:

- plugins/_official/atoms/ ships four canonical atom SKILL.md +
  open-design.json pairs (discovery-question-form, todo-write,
  direction-picker, critique-theater). Each pair is a fully valid
  plugin that the existing local-folder installer can pick up; the
  manifest declares od.kind='atom' so the future doctor warns when a
  pipeline references the matching id but the bundled folder is
  missing.

- apps/daemon/src/plugins/bundled.ts owns the boot walker:
  registerBundledPlugins({ db, bundledRoot }) walks one level of
  subdirectories (atoms/, scenarios/, bundles/) plus direct
  plugins, registers every match into installed_plugins under
  source_kind='bundled' / trust='bundled', and is idempotent across
  daemon restarts (upserts on every boot).

- defaultBundledRoot(workspaceRoot) returns
  <workspaceRoot>/plugins/_official; server.ts boot calls
  registerBundledPlugins(..., defaultBundledRoot(PROJECT_ROOT)) and
  prints a one-line summary. ENOENT is silent — running the daemon
  outside the dev tree just skips this step.

The full §23 lift (system.ts prompt fragments → SKILL.md bodies +
plugin.kind='atom' → composeSystemPrompt resolving each stage's
atoms[] through the registry) stays open. This commit is the
substrate slice: bundled plugins are now first-class
installed_plugins rows under the new tier, doctor / list / info /
apply all pick them up, and a future PR that wires composeSystemPrompt
to read SKILL.md bodies has zero registration work to do.

Daemon tests: 1476 → 1481 (+5 cases on plugins-bundled covering
tiered + direct layouts, idempotent reruns, ENOENT, miss-skips).

Co-authored-by: Tom Huang <1043269994@qq.com>
2026-05-09 13:00:27 +00:00
Cursor Agent
deb74d91c4
feat(plugins): wire pipeline runner into POST /api/runs (e2e-3 full contract)
Plan I1 + I4 / spec §10.1 / plan §8 e2e-3.

Lifts e2e-3 from entry-slice to the full §8 contract: 'first ND-JSON
event has kind=pipeline_stage_started'.

POST /api/runs gains a synchronous firePipelineForRun() call that
runs immediately after design.runs.create() + linkSnapshotToRun(),
BEFORE design.runs.start() schedules the agent. The first
pipeline_stage_started event lands on the run's SSE buffer ahead of
any message_chunk the agent emits. The remaining stage timeline
(stage_started/completed for stages 2..N, plus the
run_devloop_iterations audit row per stage) fires asynchronously
while the agent runs.

The stage runner is a stub that returns converged signals
(critique.score=4, preview.ok=true, user.confirmed=true) so a
non-loop pipeline walks through every stage in O(stages) time.
Loop stages still respect OD_MAX_DEVLOOP_ITERATIONS. Phase 4's atom
migration into plugins/_official/atoms/<atom>/SKILL.md will swap the
stub for a real per-stage worker that drives the agent.

Errors inside the pipeline runner emit pipeline_stage_failed onto
the run stream and never block the agent process — a buggy plugin
manifest can never deadlock a chat run.

apps/daemon/tests/plugins-headless-run.test.ts gains a second case
that installs a fixture plugin with two declared stages, creates a
project + run, then reads the SSE event stream and asserts the
first observed stage event is pipeline_stage_started. The fixture
grants pipeline:* via the resolver's grantCaps[] field so the
restricted-trust capability gate stays in force for non-pipeline
runs.

Daemon tests: 1475 → 1476.

Co-authored-by: Tom Huang <1043269994@qq.com>
2026-05-09 12:51:12 +00:00
Cursor Agent
712c18dbc2
feat(cli): od atoms / skills / design-systems / craft / status / version + marketplace search
Plan H2 + H3 + H4 / spec §12.2 / §11.7.

Closes the CLI parity remainder: every UI feature reachable via
/api/* now has a matching CLI verb.

  od atoms list                       List first-party atoms.
  od atoms show <id>                  Print one atom (status + taskKinds).

  od skills list / show <id>          Wraps GET /api/skills{,/:id}.
  od design-systems list / show <id>  Wraps GET /api/design-systems{,/:id}.
  od craft list / show <id>           NEW endpoints GET /api/craft{,/:id}
                                      walk the craft/ directory and
                                      emit { id, label, bytes }.
  od status                           Alias of `od daemon status`.
  od version                          Wraps GET /api/version; --json
                                      emits the structured shape.

  od marketplace search "<query>"     New verb. Walks every configured
                  [--tag <tag>]         marketplace's plugins[] and
                                       matches by substring on
                                       name + description + tags.

The HTTP additions are GET /api/craft (catalog summary) and
GET /api/craft/:id (raw markdown body), both safe-slug-validated.

Daemon tests: 1472 → 1475 (+3 cases on craft-route covering catalog
shape, malformed slug rejection, miss-as-404).

Co-authored-by: Tom Huang <1043269994@qq.com>
2026-05-09 12:41:06 +00:00
Cursor Agent
04784013e6
feat(plugins): od plugin scaffold + od plugin export (Phase 4 author tooling)
Plan G1 + G2 / spec §14.

Two new author-side CLI verbs that close the spec §14 'one repo, every
catalog' loop without leaving the terminal.

apps/daemon/src/plugins/scaffold.ts owns the scaffold path:

  od plugin scaffold --id <id> [--title] [--description]
                     [--task-kind <new-generation|code-migration|
                                   figma-migration|tune-collab>]
                     [--mode <m>] [--scenario <s>]
                     [--out <dir>] [--with-claude-plugin]

  Writes <out>/<id>/{SKILL.md, open-design.json, README.md} (plus the
  optional .claude-plugin/plugin.json for cross-catalog publishing).
  Refuses to overwrite an existing scaffold; rejects unsafe ids.

apps/daemon/src/plugins/export.ts + POST /api/applied-plugins/export
own the export path. Three targets:

  od            → SKILL.md + open-design.json + provenance README
  claude-plugin → SKILL.md + .claude-plugin/plugin.json + README
  agent-skill   → SKILL.md + README only (every catalog accepts this)

  od plugin export <projectId> --as od|claude-plugin|agent-skill --out <dir>
  od plugin export --snapshot-id <id> --as <target> --out <dir>

The exporter pulls SKILL.md straight off the installed plugin's fsPath
when available and otherwise synthesises one from the snapshot's
frozen plugin metadata, so a publishable folder is reproducible even
after od plugin update / uninstall rotates the live source. The
generated open-design.json carries a provenance block recording
snapshot id + manifestSourceDigest so a downstream catalog viewer can
reverse-resolve the originating run.

The HTTP route is loopback-only (requireLocalDaemonRequest) because
it writes to the host filesystem; the CLI is the canonical caller.

Daemon tests: 1455 → 1465 (+10): plugins-scaffold (5 cases) +
plugins-export (5 cases).

Co-authored-by: Tom Huang <1043269994@qq.com>
2026-05-09 12:22:16 +00:00
Cursor Agent
9927707361
feat(plugins): od plugin install <name> resolves through marketplaces
Plan §3.F3 / spec §7.2 + §6 / §16 Phase 3.

`resolvePluginInMarketplaces(db, name)` walks every configured
marketplace in registration order and returns the first matching
plugin entry. The matched entry carries:
  - the canonical source string (github:owner/repo, https://…tar.gz)
    that the existing installer backends already understand
  - the marketplace trust tier and id for the audit trail
  - the optional description for surface display

POST /api/plugins/install detects bare-name sources (anything that
isn't a local path / github: / https://) and re-routes through
`resolvePluginInMarketplaces` before handing off to the installer.
On miss the route returns 404 plugin-not-found with structured error
data, so the CLI's structuredHttpFailure helper maps it to exit 65.

`od plugin install <name>` (no flag) now works end-to-end. The
existing `./folder`, `github:…` and `https://*.tar.gz` shapes are
unchanged.

Note: per spec §9, marketplace trust does NOT propagate to the
installed plugin: a plugin pulled from a 'trusted' marketplace still
defaults to its source-kind tier. Trust elevation goes through
`od plugin trust` / the Phase 3 trust UI.

Daemon tests: 1450 → 1454 (added 4 cases to plugins-marketplaces:
canonical-source resolution, case insensitivity, miss → null,
registration-order tie-break).

Co-authored-by: Tom Huang <1043269994@qq.com>
2026-05-09 12:06:59 +00:00
Cursor Agent
1ae977e066
feat(cli): od daemon start/status/stop + new HTTP daemon-lifecycle routes
Plan §6 Phase 1.5 / spec §11.7.

The desktop default `od` (no subcommand) keeps its current behaviour
for back-compat. New explicit lifecycle entry:

  od daemon start [--headless] [--serve-web] [--port <n>] [--host <addr>]
                  [--no-open] [--namespace <ns>]

  --headless / --no-open / --serve-web all imply 'do not auto-open the
  browser', so a code agent / CI runner / Docker entry point can launch
  the daemon without electron. The port + host flags fall through to
  OD_PORT / OD_BIND_HOST when unset, matching every other surface.

  od daemon status [--json] [--daemon-url <url>]
  od daemon stop   [--daemon-url <url>]

These two CLI verbs hit the new HTTP routes:

  GET  /api/daemon/status      → { ok, version, bindHost, port, pid,
                                   dataDir, mediaConfigDir, namespace,
                                   installedPlugins, shuttingDown }
  POST /api/daemon/shutdown    → 202; emits SIGTERM so the existing
                                   graceful close path runs unchanged.
                                   requireLocalDaemonRequest enforces the
                                   same loopback / origin check used by
                                   composio config writes (no public
                                   shutdown surface).

This makes `od daemon start --headless --port 17456` followed by
`od plugin install ./fixtures/sample-plugin` and the rest of the
§12.5 walkthrough reachable without electron — exactly the substrate
e2e-3 needs.

Daemon tests: 1448 → 1450 (added daemon-lifecycle with 2 cases:
status snapshot shape; shutdown rejects non-loopback callers).

Co-authored-by: Tom Huang <1043269994@qq.com>
2026-05-09 12:04:42 +00:00
Cursor Agent
0df9176c2f
feat(plugins): marketplace registry + od marketplace CLI verbs
Plan §3.B4 / spec §6 / §7.2 / §16 Phase 3 entry slice.

The storage half of federated catalogs:

- New apps/daemon/src/plugins/marketplaces.ts owns add / list / get /
  refresh / remove / set-trust over the pre-existing plugin_marketplaces
  SQLite table. Uses parseMarketplace from @open-design/plugin-runtime so
  validation matches the schema in docs/schemas/open-design.marketplace.v1.json.
  Default trust tier is 'restricted' (discovery only) per spec §9.
- HTTP routes:
  * GET    /api/marketplaces
  * POST   /api/marketplaces                      (body { url, trust? })
  * GET    /api/marketplaces/:id
  * DELETE /api/marketplaces/:id
  * POST   /api/marketplaces/:id/refresh
  * POST   /api/marketplaces/:id/trust            (body { trust })
  * GET    /api/marketplaces/:id/plugins          (catalog passthrough)
- CLI verbs: `od marketplace add/list/info/refresh/remove/trust`.
  --trust accepts 'trusted', 'restricted', or 'official'.

Phase 3 follow-up wires `od plugin install <name>` resolution + the trust
UI on top; this commit is the data-layer half so the CLI can already
register and inspect catalogs.

Daemon tests: 1438 → 1443 (added plugins-marketplaces with 5 cases).

Co-authored-by: Tom Huang <1043269994@qq.com>
2026-05-09 11:37:12 +00:00
Cursor Agent
b9d40094b5
feat(plugins): github tarball + https archive install sources
Plan §3.A6 / spec §7.2.

The installer gains a top-level dispatcher `installPlugin` that picks
the right backend off the source string:

  - ./folder, /abs/path  → installFromLocalFolder (existing behavior)
  - github:owner/repo[@ref][/subpath]
                          → fetch codeload tarball, extract via tar.x,
                            optionally chroot into a subpath, then re-use
                            the local backend for copy / persist
  - https://*.tar.gz, *.tgz
                          → same archive backend, recorded as source_kind=url

Hard guards inherited from spec §7.2:
  - Symbolic / hard link entries inside the archive abort the install with
    a clean error.
  - Path-traversal segments (..) abort.
  - Total extracted size is measured against maxBytes (50 MiB default) and
    rejected if exceeded.
  - Local-folder backend now preserves the recorded source / source_kind
    when called from the archive backend so installed_plugins records
    accurate provenance.

POST /api/plugins/install accepts the new shapes; the SSE event stream
shape is unchanged. The fetcher is pluggable so tests don't need network.

apps/daemon/package.json adds tar@^7 + @types/tar@^6.

Daemon tests: 1429 → 1434 (added plugins-installer-archive with 5 cases).

Co-authored-by: Tom Huang <1043269994@qq.com>
2026-05-09 11:26:54 +00:00
Cursor Agent
39820be28b
feat(plugins): snapshot GC worker + od plugin run shorthand
Plan §3.A5 (snapshot GC) + §3.B3 (od plugin run shorthand).

- New apps/daemon/src/plugins/gc.ts wraps pruneExpiredSnapshots() in a
  periodic worker driven by OD_SNAPSHOT_GC_INTERVAL_MS. Daemon boot
  starts the worker and runs an immediate sweep so a fresh daemon does
  not wait the full interval before reaping pre-existing expired rows.
  Setting the interval to 0 disables the worker entirely.
- New HTTP endpoints:
  * GET  /api/applied-plugins                  → list all snapshots
  * GET  /api/projects/:projectId/applied-plugins → list snapshots for
    one project
  * POST /api/applied-plugins/prune { before? } → operator escape hatch
    that calls pruneExpiredSnapshots() with the optional cutoff so a
    hosted operator can force-delete unreferenced rows older than ts.
- New CLI verbs:
  * od plugin snapshots list  [--project <id>]
  * od plugin snapshots prune [--before <unix-ms>]
  * od plugin run <id> --project <projectId> [--inputs <json>]
    [--grant-caps a,b] (apply + run start shorthand). Capability gate
    failures map to exit 66; missing-input to exit 67.
- ConnectorApiErrorCode gains CONNECTOR_NOT_GRANTED so the §3.A3 gate
  rejection can use the canonical sender.

Daemon tests: 1425 → 1429 (added plugins-snapshot-gc with 4 cases).

Co-authored-by: Tom Huang <1043269994@qq.com>
2026-05-09 11:21:12 +00:00
Cursor Agent
44ea30c986
feat(plugins): trust mutation + connector tool-token gate
Plan §3.A2 (trust mutation) and §3.A3 (connector capability gate).

- New POST /api/plugins/:id/trust accepts { capabilities[], action: 'grant' | 'revoke' }
  and unions the spec §5.3 vocabulary into installed_plugins.capabilities_granted.
  Unknown / malformed strings come back as HTTP 400 with the rejected list so the
  CLI can surface exit-2 usage advice. The implicit prompt:inject floor is
  preserved on revoke so a plugin never falls below the spec minimum.
- trust.ts gains validateCapabilityList / grantCapabilities / revokeCapabilities.
  These are the only writers of installed_plugins.capabilities_granted outside
  of install.
- od plugin trust calls the new endpoint instead of printing the deferred
  Phase 3 stub. --revoke flag and --grant-caps flag added; PLUGIN_BOOLEAN_FLAGS
  / PLUGIN_STRING_FLAGS extended.
- Tool-token grants now optionally carry pluginSnapshotId / pluginTrust /
  pluginCapabilitiesGranted. The mint site in startChatRun resolves these
  off the run's appliedPluginSnapshotId so the connector execute route can
  re-validate per-call without re-reading SQLite.
- /api/tools/connectors/execute calls a new pure helper
  checkConnectorAccess(grant, connectorId). Trusted / bundled implicitly
  carry connector:*; restricted plugins must list connector:<id> (or the
  coarse connector capability) — otherwise 403 CONNECTOR_NOT_GRANTED.

Daemon test suite: 1417 / 1417 (added plugins-trust + plugins-tool-token-gate).

Co-authored-by: Tom Huang <1043269994@qq.com>
2026-05-09 11:14:51 +00:00
Cursor Agent
aa3f857653
feat(plugins): wire snapshot resolver into POST /api/projects and /api/runs
Adds the snapshot side-effect bridge between the pure applyPlugin() and
the daemon's existing project / run create paths (plan §3.A1, spec §11.5).

- New apps/daemon/src/plugins/resolve-snapshot.ts owns resolution: explicit
  appliedPluginSnapshotId or pluginId in the request body produces a
  persisted AppliedPluginSnapshot, validates capability gates, and pins the
  row to the new project / conversation / run. Missing-input maps to
  HTTP 422 (exit 67), capabilities-required to HTTP 409 (exit 66),
  not-found to HTTP 404 (exit 65), stale to HTTP 409 (exit 72).
- snapshots.ts gains linkSnapshotToProject / linkSnapshotToConversation /
  pruneExpiredSnapshots. Snapshot becomes referenced (expires_at = NULL)
  the moment any of those links land, per PB2 reproducibility-first.
- runs.ts persists appliedPluginSnapshotId / pluginId on the in-memory
  run object and surfaces them on statusBody().
- POST /api/runs and POST /api/projects gained pluginId / pluginInputs /
  appliedPluginSnapshotId / grantCaps fields. Legacy bodies behave
  unchanged.
- /api/plugins/:id/apply gains a grantCaps[] union for ephemeral
  capability grants scoped to the snapshot.
- /api/plugins/:id/doctor and /api/plugins/:id/apply share a new
  loadPluginRegistryView() helper instead of inlining the catalog walk.
- genui/index.ts disambiguates the respondSurface re-export so the public
  registry version stays canonical and the store-only writer is reached
  via respondSurfaceRow.

Daemon test suite stays green (1405 / 1405).

Co-authored-by: Tom Huang <1043269994@qq.com>
2026-05-09 11:09:15 +00:00
pftom
89be57b2c4 feat(genui): introduce GenUI surface management and event handling
- Added a new GenUI module for managing user interface surfaces, including creation, response handling, and state synchronization.
- Implemented API endpoints for listing and responding to GenUI surfaces associated with runs and projects.
- Introduced event types and payload helpers for GenUI surface events, enhancing the interaction model for headless operations.
- Established a persistent state writer for GenUI surfaces, ensuring reliable data management and retrieval.
- Enhanced the plugin system to support auto-derived OAuth prompts for required connectors, improving user experience during plugin application.
2026-05-09 18:44:04 +08:00
pftom
4c7cd5d9f2 feat(plugins): introduce plugin system with installation and management capabilities
- Added support for a new plugin system, allowing users to install, uninstall, and manage plugins through the daemon.
- Implemented API endpoints for listing installed plugins, retrieving plugin details, and applying plugins with input validation.
- Introduced a plugin doctor feature to validate plugin manifests and check for issues before application.
- Established a plugin persistence layer with SQLite migrations for managing installed plugins and their metadata.
- Enhanced the CLI with commands for plugin operations, improving user interaction with the plugin ecosystem.
2026-05-09 18:24:44 +08:00
pmasadali20776-ui
b578b93f3f
Bug FIx: Media generation task state is volatile and lost on daemon restart #648 (#884)
* feat: implement media tasks persistence

* fix(daemon): satisfy exactOptionalPropertyTypes in media-tasks-routes test

`process.env.OD_DATA_DIR` is `string | undefined`, but `openDatabase`'s
options accept `{ dataDir?: string }`. Under the daemon tsconfig's
exactOptionalPropertyTypes the two are not assignable. Spread the key
in only when defined.

* fix(daemon): restore mcp-config / mcp-oauth / mcp-tokens imports in server.ts

The earlier 'Merge branch main into main' resolved the import-block
conflict by keeping only the new media-tasks import and dropping the
three pre-existing import blocks. server.ts still uses 13+ symbols
from those modules (PendingAuthCache, MCP_TEMPLATES, beginAuth,
readMcpConfig, getToken, etc.), so the daemon crashed at startup with
'ReferenceError: PendingAuthCache is not defined' the moment Playwright
booted it. Restore the three import blocks verbatim from main.

---------

Co-authored-by: lefarcen <935902669@qq.com>
2026-05-09 00:00:18 +08:00
tenderpooh
109722de3a
feat(desktop): export artifacts directly to PDF (#532)
* feat(desktop): export artifacts directly to PDF

* fix(desktop): PDF 내보내기 기본 여백 제거
2026-05-08 23:42:12 +08:00
Siri-Ray
208f09c60e
fix: settle completed runs and clean up shutdown children (#924)
* fix: clean up completed and shutting down runs

* fix: bound daemon CLI shutdown

Generated-By: looper 0.6.0 (runner=fixer, agent=codex)

* fix: harden daemon shutdown cleanup

Generated-By: looper 0.6.0 (runner=fixer, agent=codex)

* fix: harden daemon shutdown cleanup

Generated-By: looper 0.6.0 (runner=fixer, agent=codex)

* test: align acp abort fake with typed child
2026-05-08 21:05:22 +08:00
nettee
ef9ca7baff
fix(daemon): typecheck core server paths (#952) 2026-05-08 20:43:51 +08:00
nettee
32d820e4ee
fix(daemon): typecheck leaf modules (#943)
* update drift

* fix(daemon): typecheck leaf modules

* fix(daemon): decode Qoder stdout buffers

Generated-By: looper 0.5.6 (runner=fixer, agent=opencode)
2026-05-08 20:01:25 +08:00
Bryan A
e13adf2e63
feat(daemon): finalize design package endpoint (closes #450) (#832)
* feat(daemon): scaffold /api/projects/:id/finalize/anthropic (refs #450)

Phase C of the PR 2 plan for issue #450: scaffold the route + module
shape so subsequent phases (D-I) land function bodies and tests against
a stable surface that already passes typecheck.

What lands here:
- apps/daemon/src/finalize-design.ts: module-level constants
  (DEFAULT_BASE_URL, DEFAULT_MAX_TOKENS=16000, INPUT_BODY_CAP_BYTES=384KiB,
  LOCK_FILENAME=.finalize.lock, OUTPUT_FILENAME=DESIGN.md,
  DEFAULT_TIMEOUT_MS=120s); inline interfaces for the request/response
  shape (kept out of packages/contracts per scope rules); two error
  classes - FinalizePackageLockedError (mirrors PR #493's
  TranscriptExportLockedError) and FinalizeUpstreamError (carries upstream
  HTTP status for the route's error mapping); function stub that throws
  "not yet implemented".
- apps/daemon/tests/finalize-design.test.ts: vitest harness with
  describe.skip placeholder so the file imports cleanly. Real cases land
  in phases D-I. Default-import of node:fs (per memory: vi.spyOn cannot
  redefine on the frozen ESM Module Namespace; CJS exports object is
  mutable).
- apps/daemon/src/server.ts: route handler at
  POST /api/projects/:id/finalize/anthropic, slotted next to the
  existing :id/deploy* family. Validates apiKey/model non-empty, optional
  baseUrl via the existing validateExternalApiBaseUrl closure (forbidden
  -> 403, invalid -> 400), optional maxTokens positive number; calls
  getProject (404 on miss); calls finalizeDesignPackage (which throws,
  caught and mapped to 500 for now); maps known error classes
  (FinalizePackageLockedError -> 409, FinalizeUpstreamError -> 502)
  pre-emptively.

Path shape rationale (Bryan-confirmed): project-scoped path matches every
sibling /api/projects/:id/* route in server.ts (deploy, deployments,
deploy/preflight); provider-namespaced segment leaves a clean expansion
line for /api/projects/:id/finalize/openai etc. as follow-ups.

Field-name rationale: apiKey, baseUrl, model, maxTokens match
ProxyStreamRequest verbatim (packages/contracts/src/api/proxy.ts:8-19)
so a future caller can reuse the same body shape. baseUrl is optional
here (intentional divergence from the proxy at server.ts which requires
it) so standard Anthropic users do not need to set it; Bedrock /
self-hosted-proxy users still can.

Verification: pnpm --filter @open-design/daemon typecheck exits 0;
finalize-design.test.ts loads cleanly with 1 skipped placeholder; no
other tests touched.

Refs nexu-io/open-design#450 (PR 2 scaffold; pipeline body in subsequent
commits)

* feat(daemon): transcript truncation helper for /finalize prompt

Phase D of the PR 2 plan for issue #450: lands the helper that bounds
the transcript section of the synthesis prompt.

Why this exists: real-world signal at authoring time was a local project
transcript already at 3.95 MB. Anthropic's claude-opus-4-7 context cap
is roughly 200K tokens (~700 KB at typical density). Inserting an
unbounded transcript would 4xx upstream on the first real call. This
helper keeps the on-disk .transcript.jsonl lossless (PR #493's contract)
while making the prompt-inclusion bounded.

Strategy:
- Cap output at INPUT_BODY_CAP_BYTES (384 KiB) so the prompt has room
  for the system prompt + design system body + current artifact + room
  for the synthesis output.
- Always preserve the header line - it carries projectId, schemaVersion,
  conversation/message counts, attachment counts; synthesis quality
  depends on knowing the original sizes.
- Split equal byte budgets between head and tail so both project genesis
  and most-recent intent survive. Two thinking segments separated only
  by mid-session truncation lose the same kind of boundary that PR #493
  preserves between thinking blocks - that's accepted; smarter
  semantic chunking is a follow-up.
- Insert a single `{"kind":"truncated","reason":"size","omittedBytes":N}`
  sentinel JSON line between the head and tail so a synthesis consumer
  can detect the gap. omittedBytes is the difference between the
  original UTF-8 byte length and the output's UTF-8 byte length.
- If the head + tail budgets together cover the whole body (e.g. all
  message lines are tiny), no marker is emitted - the output is the
  input verbatim.

Tests:
- "returns the input verbatim when the JSONL fits under the 384 KiB cap"
  pins that small transcripts pass through unchanged with no marker.
- "head+tail truncates with a single marker line when the JSONL exceeds
  the 384 KiB cap" pins that output is bounded, header survives, exactly
  one marker emitted with non-zero omittedBytes, both ends of the body
  preserved, and at least one middle message omitted.

Suite delta: +2 tests in finalize-design.test.ts.

Refs nexu-io/open-design#450

* fix(daemon): resolve noUncheckedIndexedAccess in truncateTranscriptForPrompt

D1 (0eaa123) shipped with `body[headIndex]` and `body[i]` typed as
`string | undefined` under TypeScript's `noUncheckedIndexedAccess`
strict mode. Local typecheck would have caught it but the prior
verification piped through `tail` which masked the non-zero exit code
of `tsc`. Coalesce each access via `?? ''` (the array is from
`String.split('\n')` so undefined elements are not actually reachable;
the coalesce is a type-narrowing convenience, not a behavior change).

Verification: `pnpm --filter @open-design/daemon typecheck` exits 0;
`pnpm --filter @open-design/daemon test finalize-design` shows 2/2 +
1 skipped, identical to the pre-fix run.

Refs nexu-io/open-design#450

* feat(daemon): current-artifact resolver for /finalize

Phase E of the PR 2 plan for issue #450: resolves which artifact (if
any) accompanies the transcript + design system in the synthesis prompt.

Priority order (Bryan-locked in plan §6):
  1. The file referenced by tabs.is_active = 1 IF an
     <name>.artifact.json sidecar exists on disk. Sidecar presence is
     the discriminator: an inferred manifest from
     `inferLegacyManifest` (e.g. for a bare .html with no sidecar)
     does NOT count, and an active tab pointing at a non-artifact file
     (.md, .txt) falls through.
  2. Newest project file with a real .artifact.json sidecar, sorted by
     manifest.updatedAt descending. Files without an updatedAt sort
     last so legacy pre-streaming manifests do not get accidentally
     promoted.
  3. Returns null - "no artifact in scope". The Phase H caller will
     emit `artifact: null` in the response and the prompt's "Current
     artifact" section will read "none".

Sidecar presence is checked via `existsSync` on the on-disk path, NOT
via the `artifactManifest` field returned by readProjectFile/listFiles
(those run inferLegacyManifest as a fallback for known kinds, which
would otherwise cause a bare .html with no sidecar to look like an
artifact).

Tests:
- "returns the active-tab artifact when its sidecar is present, even
  if a newer artifact exists elsewhere": pinned.html (older
  updatedAt) is in the active tab; newer.html (newer updatedAt) is
  not. Resolver returns pinned.html - intent (active tab) beats
  recency.
- "falls through to newest .artifact.json when active tab points at a
  non-artifact file": README.md is the active tab (no sidecar);
  design.html has a real sidecar. Resolver falls through and returns
  design.html.
- "returns null when no active tab and no .artifact.json sidecars
  exist": only a README.md is in the project; no tabs row. Resolver
  returns null.

Suite delta: +3 tests in finalize-design.test.ts (5 active total).

Refs nexu-io/open-design#450

* feat(daemon): synthesis prompt construction for /finalize

Phase F of the PR 2 plan for issue #450: builds the system + user
prompts that get sent to Anthropic's Messages API in the synthesis
call. Pure function; no IO, no side effects.

System prompt (literal, stored as a module-level constant): instructs
Claude to emit a DESIGN.md document with a fixed 7-heading structure
(# DESIGN.md / ## Summary / ## Brand & Voice / ## Information
Architecture / ## Components & Patterns / ## Visual System / ## Open
Questions / ## Provenance). The Provenance section is required to list
project ID, design system, current artifact, transcript message count,
and the UTC generation timestamp.

User prompt (built at runtime): structured payload with the truncated
transcript JSONL, the design system body, and the current artifact
body, each under a ## heading. Missing inputs (no design system
selected, no artifact in scope) produce explicit "none" headings +
parenthetical placeholder body so Claude does not hallucinate content
for absent sections.

Truncation is the caller's concern - this function does not
re-truncate. The caller (Phase H pipeline) feeds in a JSONL that has
already been bounded by truncateTranscriptForPrompt.

Tests:
- "includes the transcript JSONL verbatim and the generation context":
  pins all section headings, the transcript body verbatim, the design
  system body verbatim, the artifact body verbatim, and every
  generation-context line.
- "falls back to \"none\" + parenthetical when no design system is
  selected": designSystemId=null and designSystemBody=null -> heading
  reads "## Active design system: none" with the parenthetical body.
- "falls back to \"none\" + parenthetical when no artifact is in
  scope": artifact=null -> heading reads "## Current artifact: none"
  with the parenthetical body.

Suite delta: +3 tests in finalize-design.test.ts (8 active total).

Refs nexu-io/open-design#450

* feat(daemon): Anthropic call + retry strategy for /finalize

Phase G of the PR 2 plan for issue #450: lands the upstream Claude
Messages API call with a single transient-error retry, plus the
response extractor that turns Anthropic's content array into the
DESIGN.md body.

What lands here:
- appendVersionedApiPath: inlined from the connectionTest helper at
  apps/daemon/src/connectionTest.ts:188-195 (it is not exported there).
  Appends /v1/messages when the base URL has no /vN segment, otherwise
  appends /messages directly. Same semantics; ~5 lines.
- callAnthropicWithRetry: POSTs to <base>/v1/messages with the canonical
  Anthropic headers (content-type, x-api-key, anthropic-version:
  2023-06-01) and body shape ({ model, max_tokens, system, messages,
  stream:false }). One retry on transient (HTTP 429 or 5xx); on terminal
  failure throws FinalizeUpstreamError carrying the upstream HTTP
  status and raw body text. The route handler in Phase I maps status
  to AUTH_FAILED / RATE_LIMITED / UPSTREAM_FAILED and runs the body
  through redactSecrets before exposing it as `details`.
- extractDesignMd: concatenates content[].text for every block where
  type === 'text', preserving order. Throws FinalizeUpstreamError(502)
  on three malformed-response shapes: non-object payload, missing
  content array, zero text blocks. The route handler maps the throw
  to 502 UPSTREAM_FAILED so synthesis cannot land a half-empty
  DESIGN.md on disk.
- Test-only `_sleepMs` injection on the call params so the retry-delay
  sleep is instant under vitest. Default sleep uses setTimeout.

Retry posture (1 retry on transient) is opinionated; the maintainer's
"standard exponential backoff" answer was directional and a single
retry matches the existing daemon's posture (transcript export and
connectionTest do zero retries) while staying inside the daemon's
blocking-fast posture for /finalize.

Tests:
- callAnthropicWithRetry: throws on 401 with no retry; retries once
  on 429 and resolves on second 200; throws after both 5xx attempts;
  propagates AbortError when signal is pre-aborted.
- extractDesignMd: concatenates ordered text blocks; throws on
  missing content array; throws on content with zero text blocks.

A spurious typecheck error from `exactOptionalPropertyTypes` (signal
typed as AbortSignal | undefined where RequestInit expects
AbortSignal | null) was resolved by conditionally spreading signal
into the RequestInit literal.

Suite delta: +7 tests in finalize-design.test.ts (15 active total).

Refs nexu-io/open-design#450

* feat(daemon): wire /finalize pipeline end-to-end

Phase H of the PR 2 plan for issue #450: stitches together every
phase D-G primitive into the full finalizeDesignPackage pipeline that
the route handler in Phase I will expose over HTTP.

Pipeline (in execution order, all inside a try/finally that always
releases the lockfile):
1. getProject(db, projectId): defensive 404 (the route validates first;
   this throw catches direct CLI/script callers).
2. mkdirSync(<projectDir>, { recursive: true }): some projects have DB
   rows but no on-disk dir yet (PR #493's same fix).
3. fs.openSync(.finalize.lock, 'wx'): EEXIST -> FinalizePackageLockedError
   (mirror PR #493's TranscriptExportLockedError).
4. exportProjectTranscript(db, projectsRoot, projectId, { now }): produces
   .transcript.jsonl on disk; we read the body and run it through
   truncateTranscriptForPrompt to bound the prompt-inclusion size.
5. readDesignSystem(designSystemsRoot, designSystemId): returns null when
   the project has no design_system_id selected, when the design system
   directory does not exist, or when the DESIGN.md file is missing.
6. resolveCurrentArtifact(db, projectsRoot, projectId): active tab ->
   newest .artifact.json by manifest.updatedAt -> null.
7. buildSynthesisPrompt({...}): system + user prompt (per Phase F).
8. callAnthropicWithRetry({...}): one retry on 429/5xx; throws
   FinalizeUpstreamError on terminal failure.
9. extractDesignMd(payload): concatenates content[].text blocks; throws
   FinalizeUpstreamError(502) on malformed shape.
10. Atomic write: writeFileSync({flag:'wx'}) -> reopen for fsync ->
    rename. Errors unlink tmp before rethrowing.
11. Lock release in finally (always closeSync + unlinkSync).

Bounded blocking: the function uses its own AbortController + 120s
timeout when the caller does not supply a signal. Caller-supplied
signal takes precedence.

Type tightening: switched the local Db interface to
`type Db = Database.Database` (better-sqlite3) so the function signature
is compatible with `exportProjectTranscript`'s typed parameter. Source
file already had a `better-sqlite3` import in claude-design-import area
of the daemon, so no new dependency.

Tests:
- "writes DESIGN.md atomically on the happy path": end-to-end with
  seeded project + conversation + 2 messages + design system on disk;
  asserts file at exact path + body bytes match the fetch mock.
- "response carries every documented field with correct types":
  designMdPath/bytesWritten/model/inputTokens/outputTokens/artifact/
  transcriptMessageCount/designSystemId all present and typed.
- "emits design system 'none' in the prompt when no design_system_id is
  set": fetch mock asserts on the body it receives.
- "throws FinalizePackageLockedError when .finalize.lock is already
  held": pre-create lockfile; assert throw + DESIGN.md not written +
  pre-existing lock NOT unlinked (we did not own it).
- "replaces an existing DESIGN.md atomically on a second finalize":
  inject a sentinel between two finalize calls; assert sentinel is
  gone after second run.
- "cleans up tmp file AND lock file on every error path": mock
  fs.writeFileSync to throw on the tmp path; assert no DESIGN.md.tmp.*
  remain, no DESIGN.md, no .finalize.lock.
- "uses the default https://api.anthropic.com baseUrl when baseUrl is
  omitted": fetch URL begins with the default; baseUrl=undefined path.

vi.restoreAllMocks() now runs in afterEach so the writeFileSync spy
from the cleanup test does not leak into subsequent tests.

Suite delta: +7 tests in finalize-design.test.ts (22 active total).

Refs nexu-io/open-design#450

* feat(daemon): /finalize HTTP route handler + error mapping

Phase I of the PR 2 plan for issue #450: replaces the Phase C stub's
catch-all 500 with status-aware error mapping that surfaces the right
HTTP status + error code for each documented failure mode, and adds
HTTP-layer tests that boot startServer to exercise the route's
validation branches.

Route handler changes:
- :id format guard: an inline regex matching isSafeId at
  apps/daemon/src/projects.ts:556-558 rejects unsafe ids with 400
  BAD_REQUEST before any DB or filesystem work. Without this, an id
  like 'bad!id' would either fail getProject as 404 (wrong code) or
  reach the function and throw 'invalid project id' (mapped to 500).
- FinalizeUpstreamError mapping is now status-aware:
  - upstream 401 -> 401 AUTH_FAILED
  - upstream 429 -> 429 RATE_LIMITED
  - upstream 5xx (or our own 502 sentinel for malformed responses)
    -> 502 UPSTREAM_FAILED
  In all cases the upstream raw text is run through redactSecrets so
  the apiKey cannot leak through `details` even if the upstream
  echoes the inbound headers.
- AbortError mapping: when the 120s AbortController fires (or the
  caller pre-aborted the signal), surface as 503 TIMEOUT.
- Default case: console.error the error per daemon convention; client
  sees 500 INTERNAL with the message routed through redactSecrets.
- Imported redactSecrets alongside the existing connectionTest
  imports (apps/daemon/src/server.ts:51).

HTTP-layer tests (boot startServer({port:0,returnServer:true}) once
in beforeAll, mirror the proxy-routes.test.ts pattern):
- "400 BAD_REQUEST when baseUrl is not a valid URL (test #13)":
  baseUrl='not-a-url'.
- "403 FORBIDDEN when baseUrl points at a private internal IP (test
  #14)": baseUrl='http://10.0.0.1'. Note: validateBaseUrl explicitly
  allows loopback (for local OpenAI-compatible servers) and only
  blocks non-loopback private IPs (10/8, 172.16/12, 192.168/16,
  fc00::/7, fe80::/10).
- "400 BAD_REQUEST when apiKey is missing (test #15)": apiKey omitted.
- "400 BAD_REQUEST when :id contains characters outside the safe-id
  regex (test #16)": id='bad!id' contains '!' which is not in
  [A-Za-z0-9._-].

Suite delta: +4 tests (26 active in finalize-design.test.ts).
Full daemon suite: 1078/1078 pass; baseline+26 (the +5 above plan
target reflects retry+extract split into more granular unit tests
than originally enumerated; all real, none skipped).

Refs nexu-io/open-design#450

* fix(daemon): tighten isSafeId to reject pure-dot project ids

Addresses the P1 path-traversal finding from @lefarcen on PR #832
(https://github.com/nexu-io/open-design/pull/832#discussion_r3202512644).

The pre-fix `isSafeId` at apps/daemon/src/projects.ts:556-558 used
regex `/^[A-Za-z0-9._-]{1,128}$/` which permitted pure-dot ids
(`.`, `..`, `...`) because `.` is in the character class. `projectDir`
and `resolveProjectDir` both delegated to `isSafeId`, so an id of
`..` would resolve to the PARENT of `.od/projects/` via `path.join`.

Threat model (per @lefarcen):
- An attacker creates a project row whose stored id is `..` (or
  another pure-dot variant) — for instance via a workflow that
  writes the row directly without going through the API. Subsequent
  finalize/write ops keyed by that id then escape the project tree.
- A direct CLI / scripted caller passing `..` as the project id
  reaches the function without HTTP normalization saving us. (Express
  normalizes %2e%2e to .. and collapses path segments, which yields
  404 for the URL `/api/projects/%2e%2e/...` in practice — but that's
  Express's protection, not ours.)

Fix:
- isSafeId now explicitly rejects pure-dot ids (`/^\.+$/.test(id)`)
  before the char-class regex check. Empty string and inputs longer
  than 128 chars are also rejected explicitly so the function fails
  closed on edge cases.
- isSafeId is now exported from apps/daemon/src/projects.ts so the
  /finalize route handler in apps/daemon/src/server.ts can use the
  same validator instead of re-implementing the regex inline. This
  prevents drift between the route guard and the projectDir guard,
  which was how this hole originally appeared.

Tests (in finalize-design.test.ts because that's where the threat was
flagged; isSafeId is daemon-wide so a dedicated test file would also
work):
- isSafeId rejects `.`, `..`, `...`, `....`
- isSafeId rejects ids with `/`, `\`, `!`, leading whitespace
- isSafeId rejects empty string and >128 chars
- isSafeId rejects non-string inputs (null/undefined/number)
- isSafeId accepts plain ids, ids with mid-string dots, UUIDs, single chars

Suite delta: +7 tests (33 active in finalize-design.test.ts).
Full daemon suite: 1085/1085.

Refs nexu-io/open-design#832

* fix(daemon): address PR #832 P1 findings — imported folders + network 502

Addresses two of the three P1 findings from @lefarcen on PR #832:

1. Imported-folder projects route DESIGN.md to metadata.baseDir
   (https://github.com/nexu-io/open-design/pull/832#discussion_r3202512656,
   also flagged independently by @chatgpt-codex-connector at #discussion_r3202430470)

   The pipeline previously called `projectDir(projectsRoot, projectId)`
   unconditionally, which resolves to `.od/projects/<id>`. For projects
   created via /api/import/folder the project row's `metadata.baseDir`
   carries the user's actual folder; without threading metadata through,
   finalize would silently land DESIGN.md in the hidden daemon data dir
   and the current-artifact resolver would miss the user's real files.

   Fix: switch from `projectDir` to `resolveProjectDir(projectsRoot,
   projectId, metadata)` in both `finalizeDesignPackage` and
   `resolveCurrentArtifact`. Thread `project.metadata` (from
   `getProject`'s normalized row) through both call paths. The resolver
   gets a new optional `metadata` parameter; native projects pass null
   and get identical behavior.

2. Network failures and JSON parse errors now map to 502 UPSTREAM_FAILED
   (https://github.com/nexu-io/open-design/pull/832#discussion_r3202512661)

   Pre-fix, only HTTP-non-OK responses were wrapped as
   FinalizeUpstreamError. DNS failures (ECONNREFUSED, ENOTFOUND), fetch
   TypeErrors, and `response.json()` SyntaxErrors fell through to the
   route's catch-all and surfaced as 500 INTERNAL — incorrect: those are
   upstream-level failures, not daemon bugs.

   Fix:
   - Wrap callAnthropicWithRetry in a try/catch that passes
     FinalizeUpstreamError and AbortError through verbatim, but rewraps
     any other thrown error as FinalizeUpstreamError(502, '', message).
   - Wrap response.json() in a try/catch that rewraps SyntaxError as
     FinalizeUpstreamError(502, '', "upstream Anthropic returned non-JSON
     body: ...").
   - The route handler's existing FinalizeUpstreamError mapping then
     correctly maps these to 502 with the message in `details` (run
     through redactSecrets first).

Tests:
- "writes DESIGN.md under metadata.baseDir for imported-folder projects":
  inserts a project row with metadata.baseDir pointing at a
  user-folder temp dir; asserts result.designMdPath lands there AND
  the hidden .od/projects/<id> dir does NOT contain a DESIGN.md.
- "rewraps fetch network rejection as FinalizeUpstreamError(502)":
  fetchImpl throws TypeError with cause.code='ENOTFOUND'; assert thrown
  error has name=FinalizeUpstreamError and status=502.
- "rewraps 200 with non-JSON body as FinalizeUpstreamError(502)":
  fetchImpl returns 200 with text/html body; response.json() throws
  SyntaxError internally; assert FinalizeUpstreamError(502).

Suite delta: +3 tests (36 active in finalize-design.test.ts).
Full daemon suite: green at last check; will re-verify before push.

Refs nexu-io/open-design#832

* refactor(daemon): move /finalize DTOs to contracts + map error codes + validate active-tab

Addresses the P2 and P3 findings from @lefarcen on PR #832:

P2 — Error codes + DTOs not in packages/contracts
  https://github.com/nexu-io/open-design/pull/832#discussion_r3202512673

  Reverses my plan's locked decision #10 ("no contracts changes in this
  PR; inline the request/response types"). That rule came from the
  predecessor PROMPT brief's anti-pattern table; @lefarcen's review is
  fresher signal and supersedes it. Drift risk between the daemon's
  inline types and any future PR 3 web client is real.

  - New contracts module: packages/contracts/src/api/finalize.ts with
    FinalizeAnthropicRequest / FinalizeArtifactRef /
    FinalizeAnthropicResponse. Re-exported from the package root and
    made addressable via `@open-design/contracts/api/finalize` subpath.
  - Daemon source imports the canonical types from contracts and
    re-exports the public type names so internal references keep
    working without touching every call site.
  - Daemon-local error codes remapped to existing ApiErrorCode union
    members (apps/daemon/src/server.ts), per @lefarcen's suggested
    mapping:
      FINALIZE_IN_PROGRESS -> CONFLICT
      AUTH_FAILED          -> UNAUTHORIZED
      UPSTREAM_FAILED      -> UPSTREAM_UNAVAILABLE
      TIMEOUT              -> UPSTREAM_UNAVAILABLE (status 503)
      INTERNAL             -> INTERNAL_ERROR
    HTTP status codes are unchanged; only the `code` field in the
    error JSON body changed.

P3 — Active-tab name not validated before sidecar probe
  https://github.com/nexu-io/open-design/pull/832#discussion_r3202512684

  resolveCurrentArtifact now runs the active tab's name through
  validateProjectPath BEFORE composing it into a path.join expression.
  An invalid tab (traversal segments, absolute path, null byte,
  reserved segment) causes resolveCurrentArtifact to fall through to
  the newest-artifact branch rather than abort or probe outside the
  project directory.

Tests:
- "falls through (does not throw) when active tab name contains
  traversal segments": injects a malformed `tabs.name =
  '../../../etc/passwd'` row directly via SQL (bypassing production
  tab-creation validation), seeds a real artifact, asserts the
  resolver returns the real artifact rather than the malformed name.

Suite delta: +1 test (37 active in finalize-design.test.ts).
Full daemon suite: 1089/1089 green.

Refs nexu-io/open-design#832

* fix(contracts): publish /api/finalize as standalone runtime entrypoint

Addresses @mrcfps's CI-red review on PR #832
(https://github.com/nexu-io/open-design/pull/832, inline comment on
packages/contracts/package.json).

The previous J3 commit added `./api/finalize` as a type-only subpath:
the entry had only a `types` field, no `default`. That broke the
contracts package-runtime gate (packages/contracts/tests/package-
runtime.test.ts:38-47) which asserts every exports entry exposes both
a `.mjs` runtime and a `.d.ts` types target. mrcfps proposed two fixes;
this commit takes path B — make finalize a first-class published
module rather than a type-only re-export from the package root.

Path B vs path A (a peer-AI second opinion via /collaborate confirmed):
under NodeNext + ESM with exports-map semantics, TypeScript validates
re-exported symbols against the published module-identity surface.
Because the previous J3 had `./api/finalize` neither declared as an
exports-map entry nor materialized as a standalone .mjs, TS omitted
the re-exported names during package boundary analysis. Even at
runtime `import('@open-design/contracts').FINALIZE_SCHEMA_VERSION`
worked from the bundled index.mjs but the type-checker rejected it.
Path B aligns the runtime and declaration surfaces.

Changes:
- packages/contracts/esbuild.config.mjs: add `./src/api/finalize.ts`
  to entryPoints so dist/api/finalize.mjs is generated as a standalone
  module rather than only inlined into the bundled root.
- packages/contracts/package.json: re-add `./api/finalize` to the
  exports map with both `default: ./dist/api/finalize.mjs` AND
  `types: ./dist/api/finalize.d.ts`. Mirrors `./api/connectionTest`'s
  shape (the canonical pattern for first-class submodule entries).
- packages/contracts/src/api/finalize.ts: keep the runtime export
  `FINALIZE_SCHEMA_VERSION = 1` (giving the standalone module a real
  value to emit beyond the type-only interfaces) and update the
  doc-comment now that the standalone .mjs is wired.
- apps/daemon/src/finalize-design.ts: switch the type import from
  the inline declarations introduced in the prior J3 fallback to
  `import type { ... } from '@open-design/contracts/api/finalize'`.
  Re-export the names so internal references inside finalize-design.ts
  keep working without touching every call site.

Verified:
- node --input-type=module -e "import('@open-design/contracts/api/finalize').then(m=>console.log(JSON.stringify(Object.keys(m))))"
  prints ["FINALIZE_SCHEMA_VERSION"] — runtime resolution clean.
- pnpm --filter @open-design/contracts test: 6/6 (including both
  package-runtime.test.ts cases on the rebuilt exports map).
- pnpm --filter @open-design/daemon typecheck: exits 0.
- pnpm --filter @open-design/daemon test: 1089/1089 (no regression vs
  the prior J3 number).

Refs nexu-io/open-design#832

---------

Co-authored-by: DevForgeAI CI/CD Engineer <devforge-ai@development.ai>
2026-05-08 19:52:11 +08:00
ferasbusiness666
1e8926271b
Harden security scan findings and upgrade dependencies (#806)
* feat: add accent color control and launcher for Open Design

* fix: remove launcher binary from PR

* test: cover accent appearance edge cases

* Harden security scan findings and upgrade deps

* Address proxy security review

* Pin jsdom for web test stability

---------

Co-authored-by: ferasbusiness666 <ferasbusiness666@users.noreply.github.com>
Co-authored-by: lefarcen <935902669@qq.com>
2026-05-08 19:46:34 +08:00
Tom Huang
d592f6087f
feat(mcp): external MCP client with daemon-managed OAuth and 39 design-focused templates (#898)
* feat(mcp): add external MCP client with daemon-managed OAuth and 17 design-focused templates

Open Design now acts as an MCP CLIENT and surfaces tools from third-party
MCP servers to the underlying agent (Claude Code, Hermes, Kimi).

Daemon
- New mcp-config / mcp-oauth / mcp-tokens modules: persist server entries
  to .od/mcp-config.json, run the OAuth dance for HTTP/SSE servers
  end-to-end on the daemon (so cloud deployments work and tokens
  survive across turns), and inject Authorization: Bearer headers into
  the per-spawn .mcp.json the daemon writes for Claude Code (or the
  ACP mcpServers map for Hermes/Kimi).
- /api/mcp/servers and /api/mcp/oauth/{start,status,disconnect}
  endpoints, plus spawn-time wiring in agents that hands the configured
  servers to the active agent CLI.
- System-prompt directive for connected external MCPs so the model
  does not chase Claude Code's synthetic *_authenticate /
  *_complete_authentication tools when the Bearer is already pinned.

Web
- Settings -> External MCP servers panel with per-row OAuth Connect /
  Disconnect / Refresh affordances and per-row template hints.
- New "Add server" picker categorized into 7 groups
  (image-generation, image-editing, web-capture, ui-components,
  data-viz, publishing, utilities) with a search box, sticky close
  button, collapsible <details> sections (auto-expand on search),
  60vh capped scroll region, and a pinned Custom-server footer.
- ChatComposer /mcp slash and MCP picker button forward to the new
  Settings tab; AssistantMessage renders MCP tool calls inline;
  markdown autolinker handles bare http(s) URLs (incl. OAuth links)
  before italic markers so OAuth callback URLs do not get
  italic-fragmented mid-token.

Contracts
- packages/contracts/src/api/mcp.ts owns the wire shapes
  (McpServerConfig, McpTemplate with stable McpTemplateCategory
  enum, McpServersResponse, OAuth start/status/disconnect bodies, the
  postMessage payload from the OAuth callback).

Templates (17 built-in)
- image-generation: Higgsfield (OpenClaw, OAuth HTTP), Pollinations,
  Allyson (animated SVG), AWS Bedrock Image (uvx).
- image-editing: Imagician, ImageSorcery.
- web-capture: just-every screenshot-website-fast, ScreenshotOne.
- ui-components: 21st.dev Magic, shadcn/ui, FlyonUI.
- data-viz: AntV Chart, Mermaid.
- publishing: EdgeOne Pages.
- utilities: Filesystem, GitHub, Fetch.

Tests
- apps/daemon/tests/mcp-{config,oauth,tokens,spawn}.test.ts cover
  storage round-trip, OAuth helpers, token persistence, spawn-time
  wiring, every template's transport / command / args / env-field
  invariants, and the canonical category enum.
- apps/web/tests/runtime/markdown.test.tsx covers the new autolinker
  ordering rules.

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(mcp): add 21 more design-focused templates and a `design-systems` category

Expands the built-in MCP picker from 17 to 38 templates so users can compose
the full Open Design craft loop (design-system intake → generate → edit →
audit → publish) without leaving the Settings dialog. Every install spec is
verified live against the upstream README; templates that needed Go binaries,
multi-step `init` ceremonies, or massive runtime stacks (PostgreSQL + Redis
+ Ollama) are intentionally deferred so picking a template still resolves to
a working server in one click.

New `design-systems` category between `web-capture` and `ui-components`
(reflects the upstream-of-components position in the workflow). Mirrored in
`McpTemplateCategory` on both contracts and daemon, and `CATEGORY_ORDER` on
the web side.

New templates by category:

- image-generation (+4): prompt-to-asset (icons / favicons / OG / logos with
  free-tier routing across Cloudflare AI / NVIDIA NIM / HF / Stable Horde),
  Nano Banana (hosted streamable HTTP, virtual try-on + product placement),
  Seedream (hosted streamable HTTP, ByteDance Seedream v3-v5 + SeedEdit),
  fal.ai (uvx, 600+ models incl. FLUX / Kling / Hunyuan / MusicGen).
- image-editing (+3): Photopea (34 layered-editor tools — closes the PSD
  gap), Topaz Labs (AI upscale / denoise / sharpen), Transloadit (86+ media
  pipeline robots).
- web-capture (+1): Pagecast (browser → demo GIF / MP4 with auto-zoom).
- design-systems (+4, NEW category): Figma-Context (Framelink, designs →
  code), Design Token Bridge (Tailwind ⇄ CSS ⇄ Figma ⇄ M3 / SwiftUI / W3C
  DTCG + WCAG contrast), Design System Extractor (Storybook scrape),
  Aesthetics Wiki (cottagecore / dark-academia / y2k / … moodboards).
- data-viz (+2): MCP Dashboards (45+ chart types + KPI dashboards),
  Excalidraw Architect (hand-drawn architecture diagrams).
- publishing (+6): PageDrop, PDFSpark, OGForge, QRMint, Slideshot
  (HTML → PDF / PPTX / PNG with 7 themes), Deckrun (Markdown → PDF / video,
  hosted free tier with no key required).
- utilities (+1): A11y axe-core (WCAG 2.0/2.1/2.2 + color-contrast + ARIA).

Tests cover every new template's wiring (command, args, env / header
required-vs-optional, secret flag), the category enum invariant, and
in-category declaration order for image-generation, design-systems and
publishing buckets where the order is what users see in the picker. 21 new
test cases pass; full mcp-config suite is green.

Templates intentionally deferred (documented in PR body): figma-use
(needs Figma desktop with --remote-debugging-port=9222), m-moire (multi-step
`memi suite init` + daemon ceremony), gemini-media-mcp + trident-mcp (Go
binaries — no npx / uvx path), Pixelle-MCP (full app with web UI + ComfyUI
backend), storybook-addon-mcp (lives inside user's Storybook, not standalone),
primitiv (multi-step init / build / serve), ReftrixMCP (PostgreSQL + Redis +
Ollama + DINOv2), narasimhaponnada/mermaid (overlap with peng-shawn).

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(mcp): add figma-use template (write designs from chat) under design-systems

figma-use is the natural counterpart to Figma-Context already in this PR:
where Framelink reads Figma designs into the model, figma-use writes back
into the canvas (90+ tools — create frames / text / components / variants,
render JSX into Figma, export PNG/SVG, query nodes via XPath, lint for
WCAG / auto-layout / hardcoded colors, analyze design systems).

Wired as an HTTP MCP template (`http://localhost:38451/mcp`) because
`figma-use mcp serve` only exposes HTTP — there's no stdio mode in the
upstream `serve.ts`. No API key. Two prerequisites the user owns are
spelled out in the description so picking the template still resolves to
a working server: (1) start Figma with `--remote-debugging-port=9222`
(or `figma-use daemon start --pipe` on Figma 126+), and (2) leave
`npx figma-use mcp serve` running in a terminal.

Inserted between `design-system-extractor` and `aesthetics-wiki` so the
design-systems category reads as a workflow: read existing design (Figma
Context) → translate tokens (Token Bridge) → extract from Storybook
(Extractor) → write back to Figma (figma-use) → break creative block
(Aesthetics Wiki).

Tests cover the new template's transport (`http`), endpoint URL, the
empty header-fields invariant (no auth required), and bump the
design-systems group order to include it.

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(settings): i18n the External MCP / MCP server / Connectors sidebar entries and make the dialog header track the active section

The External MCP sidebar entry this PR introduces was hardcoded English
("External MCP / Add MCP tools (Higgsfield, GitHub…)"). Same for the
adjacent Connectors and MCP server entries. The dialog header was also
pinned to "Execution & model" copy, so opening Settings → External MCP
showed a header that lied about which section the user was on.

Adds six translation keys — `settings.connectorsTitle/Hint`,
`settings.mcpServerTitle/Hint`, `settings.externalMcpTitle/Hint` — and
translates them across all 17 locales (ar, de, en, es-ES, fa, fr, hu, id,
ja, ko, pl, pt-BR, ru, tr, uk, zh-CN, zh-TW).

`SettingsDialog` now derives the header title/subtitle from the active
section (11 sections total) instead of a single hardcoded pair, so each
section renders an honest header.

Co-authored-by: Cursor <cursoragent@cursor.com>

* test(e2e): pin level: 3 on dialog heading lookups for Pets and Connectors

CI's Validate workspace job (#1479) failed two Playwright cases with the
strict-mode violation:

  getByRole('dialog').getByRole('heading', { name: 'Pets' })
  resolved to 2 elements:
    1) <h2>Pets</h2>
    2) <h3>Pets</h3>

Same root cause as the unit-test fix already in this PR: the dynamic
dialog `<h2>` now echoes the section's own `<h3>` because the dialog
header tracks the active section. Disambiguate to `level: 3` so each
assertion still pins the section heading specifically (which is what
the test intends to verify).

Audit of the rest of e2e/ for `dialog.getByRole('heading', ...)` —
settings-api-protocol.test.ts looks for "OpenAI API" / "Anthropic API"
section h3s which never appear in the dialog `<h2>` (always
"Execution & model"), so those stay safe.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(mcp): bind OAuth refresh to the issuing client and skip stale tokens

Persist the OAuth client context (token endpoint, client_id, client_secret,
issuer, redirect_uri, resource) alongside the bearer token so refresh hits
the same client the refresh_token was bound to (RFC 6749 §6). The previous
refresh path re-ran beginAuth with a dummy OOB redirect URI, which kept
getOrRegisterClient from finding the original DCR client and made
providers reject the refresh on the next chat turn. Refreshes now reuse
the persisted endpoint/client pair directly.

Also stop injecting expired access tokens at spawn time when refresh is
unavailable or fails. Pinning a stale Bearer made every Claude MCP call
401 while the prompt still treated the server as connected; on that path
we now skip the entry and let the UI surface a reconnect.

Generated-By: looper 0.6.1 (runner=fixer, agent=claude-code)

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-08 17:59:20 +08:00