feat: split skills/design-templates and add finalize-design API

Phase 0 of the skills/design-templates refactor (specs/current/
skills-and-design-templates.md):

- Move ~104 rendering catalogue entries from skills/ to design-templates/
  and keep skills/ for the small set of functional skills that *do work*
  on user input (utilities, briefs, packagers).
- Add design-templates/AGENTS.md and skills/AGENTS.md describing the
  contract, and a brand-agnostic craft/ surface for opt-in craft rules.
- Daemon: add DESIGN_TEMPLATES_DIR / USER_DESIGN_TEMPLATES_DIR roots and
  an /api/design-templates surface mirroring /api/skills. Asset/example
  routes still span both registries so existing srcdoc URLs keep
  resolving across the rename.
- Web: split LibrarySection into SkillsSection + DesignSystemsSection,
  rename the EntryView "Examples" tab to "Templates", and update locales
  + the New-project picker accordingly.

Adds the finalize-design endpoint:

- New apps/daemon/src/finalize-design.ts and packages/contracts/src/api/
  finalize.ts — one-shot synthesis of a project's transcript + active
  design system + current artifact into <projectDir>/DESIGN.md via the
  Anthropic Messages API. Per-project .finalize.lock mirrors the
  transcript-export hygiene from PR #493; provider credentials are not
  persisted by the daemon.

Other supporting changes:

- README + AGENTS.md updates to document the new directory split and
  craft/ surface, plus i18n strings across 13 locales.
- Test refactors and new coverage (finalize-design, runs, sidecar
  server, plus refreshed daemon integration tests).
- .gitignore: scope the *.exe ignore to /OpenDesign.exe so legitimate
  vendor binaries are no longer hidden.
This commit is contained in:
pftom 2026-05-08 23:16:05 +08:00
parent 75cf30699b
commit b5993385f3
869 changed files with 10211 additions and 2895 deletions

2
.gitignore vendored
View file

@ -6,7 +6,7 @@ out/
.tmp/
.DS_Store
*.log
*.exe
/OpenDesign.exe
.vite
.astro/
.vscode

View file

@ -7,13 +7,13 @@ This file is the single source of truth for agents entering this repository. Rea
- Product and onboarding: `README.md`, `README.zh-CN.md`, `QUICKSTART.md`.
- Contribution and environment: `CONTRIBUTING.md`, `CONTRIBUTING.zh-CN.md`.
- Architecture and protocols: `docs/spec.md`, `docs/architecture.md`, `docs/skills-protocol.md`, `docs/agent-adapters.md`, `docs/modes.md`.
- Roadmap and references: `docs/roadmap.md`, `docs/references.md`, `specs/current/maintainability-roadmap.md`.
- Roadmap and references: `docs/roadmap.md`, `docs/references.md`, `docs/code-review-guidelines.md`, `specs/current/maintainability-roadmap.md`.
- Directory-level agent guidance: `apps/AGENTS.md`, `packages/AGENTS.md`, `tools/AGENTS.md`, `e2e/AGENTS.md`.
## Workspace directories
- Workspace packages come from `pnpm-workspace.yaml`: `apps/*`, `packages/*`, `tools/*`, and `e2e`.
- Top-level content directories: `skills/` (artifact-shape skills), `design-systems/` (brand `DESIGN.md` files), `craft/` (universal brand-agnostic craft rules a skill can opt into via `od.craft.requires`).
- Top-level content directories: `skills/` (functional skills the agent invokes mid-task — utilities, briefs, packagers; see `skills/AGENTS.md`), `design-templates/` (rendering catalogue: decks, prototypes, image/video/audio templates; see `design-templates/AGENTS.md` and `specs/current/skills-and-design-templates.md`), `design-systems/` (brand `DESIGN.md` files), `craft/` (universal brand-agnostic craft rules a skill can opt into via `od.craft.requires`).
- `apps/web` is the Next.js 16 App Router + React 18 web runtime; do not restore `apps/nextjs`.
- `apps/daemon` is the local privileged daemon and `od` bin. It owns `/api/*`, agent spawning, skills, design systems, artifacts, and static serving.
- `apps/desktop` is the Electron shell; it discovers the web URL through sidecar IPC.
@ -70,6 +70,15 @@ This file is the single source of truth for agents entering this repository. Rea
- Git commits must not include `Co-authored-by` trailers or any other co-author metadata.
## Code review guide
- Use `docs/code-review-guidelines.md` as the repository-wide review standard. That document is the operational guide; this `AGENTS.md` is the source of truth when the two disagree.
- Walk reviews top-down through `docs/code-review-guidelines.md`: Product relevance test → forbidden surfaces → ownership/scope → matching lane → checklist → comments → approval bar.
- Pick the matching review lane: default code/tests, contract and protocol changes, design-system additions, skill additions, or craft additions.
- Before reviewing changes under `apps/`, `packages/`, `tools/`, or `e2e/`, read that directory's `AGENTS.md` and apply its local boundaries.
- Blocking review feedback should focus on correctness, security/secrets, data integrity, repository boundary violations, contract/migration breakage, missing required validation, or high-risk maintainability issues.
- Only maintainers may close a PR instead of requesting changes, and only when the change is not salvageable on the existing branch (wrong target product, foreign test harness, DOM/API assumptions absent from this repo, or scripts that conflict with lifecycle rules).
## Validation strategy
- After package, workspace, or command-entry changes, run `pnpm install` so workspace links and generated dist entries stay fresh.

View file

@ -45,7 +45,7 @@
يرتكز OD على أربعة مشاريع مفتوحة المصدر:
- [**`alchaincyf/huashu-design`**](https://github.com/alchaincyf/huashu-design) — بوصلة فلسفة التصميم. سير عمل Junior-Designer، بروتوكول الأصول البصرية المؤلف من 5 خطوات، checklist مكافحة AI-slop، التقييم الذاتي خماسي الأبعاد، وفكرة "5 مدارس × 20 فلسفة تصميم" خلف منتقي الاتجاه — كل ذلك مكثّف في [`apps/web/src/prompts/discovery.ts`](apps/web/src/prompts/discovery.ts).
- [**`alchaincyf/huashu-design`**](https://github.com/alchaincyf/huashu-design) — بوصلة فلسفة التصميم. سير عمل Junior-Designer، بروتوكول الأصول البصرية المؤلف من 5 خطوات، checklist مكافحة AI-slop، التقييم الذاتي خماسي الأبعاد، وفكرة "5 مدارس × 20 فلسفة تصميم" خلف منتقي الاتجاه — كل ذلك مكثّف في [`packages/contracts/src/prompts/discovery.ts`](packages/contracts/src/prompts/discovery.ts).
- [**`op7418/guizang-ppt-skill`**](https://github.com/op7418/guizang-ppt-skill) — وضع الـ deck. مُضمَّن حرفياً تحت [`skills/guizang-ppt/`](skills/guizang-ppt/) مع الحفاظ على LICENSE الأصلية؛ تخطيطات بأسلوب المجلّات، WebGL hero، و checklist بمستويات P0/P1/P2.
- [**`OpenCoworkAI/open-codesign`**](https://github.com/OpenCoworkAI/open-codesign) — نجم UX الشمالي وأقرب أقراننا. أوّل بديل مفتوح المصدر لـ Claude-Design. اقتبسنا منه حلقة الـ artifact المُتدفّق، نمط معاينة iframe المعزول (مع React 18 + Babel مضمّنين)، لوحة الوكيل الحيّة (todos + tool calls + إمكانية المقاطعة)، وقائمة التصدير بخمسة صيغ (HTML / PDF / PPTX / ZIP / Markdown). تعمّدنا التباعد في الشكل العام — هم تطبيق سطح مكتب Electron يضمّ [`pi-ai`][piai]، ونحن تطبيق ويب + daemon محلي يفوّض المهمة لـ CLI الموجودة لديك.
- [**`multica-ai/multica`**](https://github.com/multica-ai/multica) — معمارية الـ daemon ومنظومة التشغيل. اكتشاف الوكلاء بمسح `PATH`، والـ daemon المحلي بوصفه العملية المميَّزة الوحيدة، ورؤية "الوكيل كزميل فريق".
@ -59,7 +59,7 @@
| **أنظمة تصميم مدمجة** | **129** — 2 starters مكتوبة يدوياً + 70 نظاماً للمنتجات (Linear، Stripe، Vercel، Airbnb، Tesla، Notion، Anthropic، Apple، Cursor، Supabase، Figma، Xiaohongshu، …) من [`awesome-design-md`][acd2]، إضافة إلى 57 design skill من [`awesome-design-skills`][ads] أُضيفت مباشرة تحت `design-systems/` |
| **Skills مدمجة** | **31** — 27 في وضع `prototype` (web-prototype، saas-landing، dashboard، mobile-app، gamified-app، social-carousel، magazine-poster، dating-web، sprite-animation، motion-frames، critique، tweaks، wireframe-sketch، pm-spec، eng-runbook، finance-report، hr-onboarding، invoice، kanban-board، team-okrs، …) + 4 في وضع `deck` (`guizang-ppt` · `simple-deck` · `replit-deck` · `weekly-update`). مُجمَّعة في الـ picker حسب `scenario`: design / marketing / operation / engineering / product / finance / hr / sale / personal. |
| **توليد الوسائط** | تشتغل أسطح الصورة والفيديو والصوت بالتوازي مع حلقة التصميم. **gpt-image-2** (Azure / OpenAI) للملصقات والصور الرمزية والإنفوغرافيك وخرائط المدن المرسومة · **Seedance 2.0** (ByteDance) لـ 15 ثانية t2v + i2v بطابع سينمائي · **HyperFrames** ([heygen-com/hyperframes](https://github.com/heygen-com/hyperframes)) لتحويل HTML→MP4 (إعلانات منتجات، طباعة حركية، رسومات بيانية، بطاقات اجتماعية، Logo outros). معرض **93** برومبت جاهزة للاستنساخ — 43 لـ gpt-image-2 + 39 لـ Seedance + 11 لـ HyperFrames — تحت [`prompt-templates/`](prompt-templates/) مع صور معاينة وبيانات المصدر. نفس واجهة الـ chat كما في الكود؛ المخرجات ملفات `.mp4` / `.png` حقيقية تنزل في مساحة عمل المشروع. |
| **الاتجاهات البصرية** | 5 مدارس منتقاة (Editorial Monocle · Modern Minimal · Warm Soft · Tech Utility · Brutalist Experimental) — كل واحدة تأتي بلوحة OKLch حتميّة + font stack ([`apps/web/src/prompts/directions.ts`](apps/web/src/prompts/directions.ts)) |
| **الاتجاهات البصرية** | 5 مدارس منتقاة (Editorial Monocle · Modern Minimal · Warm Soft · Tech Utility · Brutalist Experimental) — كل واحدة تأتي بلوحة OKLch حتميّة + font stack ([`packages/contracts/src/prompts/directions.ts`](packages/contracts/src/prompts/directions.ts)) |
| **إطارات الأجهزة** | iPhone 15 Pro · Pixel · iPad Pro · MacBook · Browser Chrome — دقيقة على مستوى البكسل، مُشتركة عبر الـ skills تحت [`assets/frames/`](assets/frames/) |
| **Agent runtime** | الـ daemon المحلي يُشغّل CLI داخل مجلد مشروعك — يحصل الوكيل على أدوات `Read` / `Write` / `Bash` / `WebFetch` حقيقية على نظام ملفات حقيقي، مع fallbacks على Windows لتجاوز قيود `ENAMETOOLONG` (stdin / ملف برومبت مؤقت) في كل adapter |
| **الاستيراد** | اسحب ملف ZIP مُصدَّر من [Claude Design][cd] إلى مربّع الترحيب — `POST /api/import/claude-design` يفكّه إلى مشروع حقيقي ليُكمل وكيلك من حيث توقّف Anthropic |
@ -256,7 +256,7 @@ DISCOVERY directives (turn-1 form, turn-2 brand branch, TodoWrite, 5-dim critiq
+ (deck kind, no skill seed) DECK_FRAMEWORK_DIRECTIVE (nav / counter / scroll / print)
```
كل طبقة قابلة للتركيب. كل طبقة ملف يمكنك تعديله. اقرأ [`apps/web/src/prompts/system.ts`](apps/web/src/prompts/system.ts) و [`apps/web/src/prompts/discovery.ts`](apps/web/src/prompts/discovery.ts) لرؤية العقد الحقيقي.
كل طبقة قابلة للتركيب. كل طبقة ملف يمكنك تعديله. اقرأ [`packages/contracts/src/prompts/system.ts`](packages/contracts/src/prompts/system.ts) و [`packages/contracts/src/prompts/discovery.ts`](packages/contracts/src/prompts/discovery.ts) لرؤية العقد الحقيقي.
## المعمارية
@ -567,7 +567,7 @@ open-design/
| Brutalist | خشن، طباعة عملاقة، بدون ظلال، تفاصيل قاسية | Bloomberg Businessweek · Achtung |
| Soft warm | كريم، تباين منخفض، ألوان خوخية محايدة | Notion marketing · Apple Health |
المواصفات الكاملة → [`apps/web/src/prompts/directions.ts`](apps/web/src/prompts/directions.ts).
المواصفات الكاملة → [`packages/contracts/src/prompts/directions.ts`](packages/contracts/src/prompts/directions.ts).
## توليد الوسائط
@ -657,7 +657,7 @@ OD لا يقف عند الكود. نفس واجهة الـ chat التي تنت
## ميكانيكا مكافحة AI-slop
كل المنظومة أدناه هي playbook الخاص بـ [`huashu-design`](https://github.com/alchaincyf/huashu-design)، نُقل إلى مكدّس برومبت OD وأصبح قابلاً للإنفاذ لكل skill عبر pre-flight لملفات side. اقرأ [`apps/web/src/prompts/discovery.ts`](apps/web/src/prompts/discovery.ts) للاطّلاع على الصياغة الحيّة:
كل المنظومة أدناه هي playbook الخاص بـ [`huashu-design`](https://github.com/alchaincyf/huashu-design)، نُقل إلى مكدّس برومبت OD وأصبح قابلاً للإنفاذ لكل skill عبر pre-flight لملفات side. اقرأ [`packages/contracts/src/prompts/discovery.ts`](packages/contracts/src/prompts/discovery.ts) للاطّلاع على الصياغة الحيّة:
- **نموذج الأسئلة أوّلاً.** الجولة الأولى `<question-form>` فقط — لا تفكير، لا أدوات، لا سرد. يختار المستخدم الافتراضيات بسرعة الـ radio.
- **استخراج brand-spec.** حين يُرفق المستخدم لقطة شاشة أو URL، يُجري الوكيل بروتوكولاً من خمس خطوات (locate · download · grep hex · تدوين `brand-spec.md` · vocalise) قبل كتابة CSS. **لا يخمّن ألوان الهوية من الذاكرة أبداً.**
@ -732,7 +732,7 @@ OD لا يقف عند الكود. نفس واجهة الـ chat التي تنت
| المشروع | الدور هنا |
|---|---|
| [`Claude Design`][cd] | المنتج المغلق المصدر الذي يُمثّل هذا المستودع البديل المفتوح له. |
| [**`alchaincyf/huashu-design`**](https://github.com/alchaincyf/huashu-design) | نواة فلسفة التصميم. سير عمل Junior-Designer، بروتوكول الأصول البصرية المؤلف من 5 خطوات، checklist مكافحة AI-slop، التقييم الذاتي خماسي الأبعاد، ومكتبة "5 مدارس × 20 فلسفة تصميم" خلف منتقي الاتجاه — كلّها مكثّفة في [`apps/web/src/prompts/discovery.ts`](apps/web/src/prompts/discovery.ts) و [`apps/web/src/prompts/directions.ts`](apps/web/src/prompts/directions.ts). |
| [**`alchaincyf/huashu-design`**](https://github.com/alchaincyf/huashu-design) | نواة فلسفة التصميم. سير عمل Junior-Designer، بروتوكول الأصول البصرية المؤلف من 5 خطوات، checklist مكافحة AI-slop، التقييم الذاتي خماسي الأبعاد، ومكتبة "5 مدارس × 20 فلسفة تصميم" خلف منتقي الاتجاه — كلّها مكثّفة في [`packages/contracts/src/prompts/discovery.ts`](packages/contracts/src/prompts/discovery.ts) و [`packages/contracts/src/prompts/directions.ts`](packages/contracts/src/prompts/directions.ts). |
| [**`op7418/guizang-ppt-skill`**][guizang] | skill Magazine-web-PPT المضمَّن حرفياً تحت [`skills/guizang-ppt/`](skills/guizang-ppt/) مع الحفاظ على LICENSE الأصلية. الافتراضي لوضع deck. ثقافة checklist بمستويات P0/P1/P2 مستعارة لكل skill أخرى. |
| [**`multica-ai/multica`**](https://github.com/multica-ai/multica) | معمارية الـ daemon + adapter. اكتشاف الوكلاء بمسح PATH، الـ daemon المحلي بوصفه العملية المميَّزة الوحيدة، ورؤية "الوكيل كزميل فريق". نتبنّى النموذج، لا نضمّ الكود. |
| [**`OpenCoworkAI/open-codesign`**][ocod] | أوّل بديل مفتوح المصدر لـ Claude-Design، وأقرب أقراننا. أنماط UX المُتبنّاة: حلقة الـ artifact المتدفّقة، معاينة iframe المعزولة (مع React 18 + Babel مضمّنين)، لوحة الوكيل الحيّة (todos + tool calls + قابلة للمقاطعة)، قائمة التصدير بخمس صيغ (HTML/PDF/PPTX/ZIP/Markdown)، مركز تخزين محلي أوّلاً، حقن الذوق عبر `SKILL.md`، والتمرير الأوّل لتعليقات comment-mode على المعاينة. أنماط UX لا تزال على roadmap لدينا: موثوقية surgical-edit الكاملة ولوحة tweaks يطلقها الذكاء. **نتعمّد عدم ضمّ [`pi-ai`][piai]** — open-codesign يحزمه كـ agent runtime؛ نحن نفوّض لأيّ CLI لدى المستخدم. |

View file

@ -43,7 +43,7 @@ Das ist nicht "AI versucht, etwas zu designen". Das ist eine AI, die durch den P
OD steht auf den Schultern von vier Open-Source-Projekten:
- [**`alchaincyf/huashu-design`**](https://github.com/alchaincyf/huashu-design) — der Design-Philosophie-Kompass. Junior-Designer Workflow, das 5-step brand-asset protocol, die anti-AI-slop checklist, die fünfdimensionale Self-Critique und die Idee "5 schools × 20 design philosophies" hinter unserem Direction Picker, alles verdichtet in [`apps/web/src/prompts/discovery.ts`](apps/web/src/prompts/discovery.ts).
- [**`alchaincyf/huashu-design`**](https://github.com/alchaincyf/huashu-design) — der Design-Philosophie-Kompass. Junior-Designer Workflow, das 5-step brand-asset protocol, die anti-AI-slop checklist, die fünfdimensionale Self-Critique und die Idee "5 schools × 20 design philosophies" hinter unserem Direction Picker, alles verdichtet in [`packages/contracts/src/prompts/discovery.ts`](packages/contracts/src/prompts/discovery.ts).
- [**`op7418/guizang-ppt-skill`**](https://github.com/op7418/guizang-ppt-skill) — der Deck-Modus. Unverändert unter [`skills/guizang-ppt/`](skills/guizang-ppt/) gebündelt, mit ursprünglicher LICENSE; magazinartige Layouts, WebGL-Hero, P0/P1/P2-Checklists.
- [**`OpenCoworkAI/open-codesign`**](https://github.com/OpenCoworkAI/open-codesign) — UX North Star und nächster Peer. Die erste Open-Source-Alternative zu Claude Design. Wir übernehmen den Streaming-Artifact-Loop, das sandboxed-iframe Preview Pattern (vendored React 18 + Babel), das Live-Agent-Panel (todos + tool calls + unterbrechbare Generierung) und die fünf Exportformate (HTML / PDF / PPTX / ZIP / Markdown). Wir unterscheiden uns bewusst im Formfaktor: Sie sind eine Desktop-Electron-App mit gebündeltem [`pi-ai`][piai]; wir sind eine Web-App + lokaler daemon, die an Ihre vorhandene CLI delegiert.
- [**`multica-ai/multica`**](https://github.com/multica-ai/multica) — die daemon- und runtime-Architektur. PATH-Scan-Agent-Erkennung, der lokale daemon als einziger privilegierter Prozess, die Agent-as-teammate Sichtweise.
@ -57,7 +57,7 @@ OD steht auf den Schultern von vier Open-Source-Projekten:
| **Design Systems integriert** | **72** — 2 handgeschriebene Starter + 70 Produktsysteme (Linear, Stripe, Vercel, Airbnb, Tesla, Notion, Anthropic, Apple, Cursor, Supabase, Figma, Xiaohongshu, …), importiert aus [`awesome-design-md`][acd2] |
| **Skills integriert** | **31** — 27 im `prototype` mode (web-prototype, saas-landing, dashboard, mobile-app, gamified-app, social-carousel, magazine-poster, dating-web, sprite-animation, motion-frames, critique, tweaks, wireframe-sketch, pm-spec, eng-runbook, finance-report, hr-onboarding, invoice, kanban-board, team-okrs, …) + 4 im `deck` mode (`guizang-ppt` · `simple-deck` · `replit-deck` · `weekly-update`). Im Picker nach `scenario` gruppiert: design / marketing / operation / engineering / product / finance / hr / sale / personal. |
| **Medienerzeugung** | Image-, Video- und Audio-Surfaces laufen neben dem Design-Loop. **gpt-image-2** (Azure / OpenAI) für Poster, Avatare, Infografiken, illustrierte Karten · **Seedance 2.0** (ByteDance) für 15s-cinematic text-to-video und image-to-video · **HyperFrames** ([heygen-com/hyperframes](https://github.com/heygen-com/hyperframes)) für HTML→MP4 Motion Graphics (Produkt-Reveals, kinetische Typografie, Datendiagramme, Social Overlays, Logo-Outros). **93** sofort reproduzierbare Prompts — 43 gpt-image-2 + 39 Seedance + 11 HyperFrames — unter [`prompt-templates/`](prompt-templates/), mit Vorschau-Thumbnails und Quellenangabe. Gleiche Chat-Oberfläche wie Code; gibt einen echten `.mp4` / `.png` Chip in den Projekt-Workspace aus. |
| **Visuelle Richtungen** | 5 kuratierte Schulen (Editorial Monocle · Modern Minimal · Warm Soft · Tech Utility · Brutalist Experimental), jeweils mit deterministischer OKLch-Palette + Font Stack ([`apps/web/src/prompts/directions.ts`](apps/web/src/prompts/directions.ts)) |
| **Visuelle Richtungen** | 5 kuratierte Schulen (Editorial Monocle · Modern Minimal · Warm Soft · Tech Utility · Brutalist Experimental), jeweils mit deterministischer OKLch-Palette + Font Stack ([`packages/contracts/src/prompts/directions.ts`](packages/contracts/src/prompts/directions.ts)) |
| **Device frames** | iPhone 15 Pro · Pixel · iPad Pro · MacBook · Browser Chrome — pixelgenau, skillübergreifend unter [`assets/frames/`](assets/frames/) geteilt |
| **Agent-Runtime** | Der lokale daemon startet die CLI in Ihrem Projektordner: Der Agent bekommt echte `Read`, `Write`, `Bash`, `WebFetch` gegen eine echte Festplattenumgebung, mit Windows-`ENAMETOOLONG` Fallbacks (stdin / prompt-file) in jedem Adapter |
| **Imports** | Ziehen Sie einen [Claude Design][cd] Export-ZIP in den Welcome Dialog: `POST /api/import/claude-design` parst ihn zu einem echten Projekt, damit Ihr Agent dort weiterarbeiten kann, wo Anthropic aufgehört hat |
@ -253,7 +253,7 @@ DISCOVERY directives (turn-1 form, turn-2 brand branch, TodoWrite, 5-dim critiq
+ (deck kind, no skill seed) DECK_FRAMEWORK_DIRECTIVE (nav / counter / scroll / print)
```
Jede Ebene ist kombinierbar. Jede Ebene ist eine Datei, die Sie editieren können. Lesen Sie [`apps/web/src/prompts/system.ts`](apps/web/src/prompts/system.ts) und [`apps/web/src/prompts/discovery.ts`](apps/web/src/prompts/discovery.ts), um den echten Vertrag zu sehen.
Jede Ebene ist kombinierbar. Jede Ebene ist eine Datei, die Sie editieren können. Lesen Sie [`packages/contracts/src/prompts/system.ts`](packages/contracts/src/prompts/system.ts) und [`packages/contracts/src/prompts/discovery.ts`](packages/contracts/src/prompts/discovery.ts), um den echten Vertrag zu sehen.
## Architektur
@ -494,7 +494,7 @@ Wenn der Nutzer keine Brand Spec hat, gibt der Agent ein zweites Formular mit f
| Brutalist | Roh, übergroße Type, keine Schatten, harte Akzente | Bloomberg Businessweek · Achtung |
| Soft warm | Großzügig, niedriger Kontrast, peachy Neutrals | Notion marketing · Apple Health |
Vollständige Spec → [`apps/web/src/prompts/directions.ts`](apps/web/src/prompts/directions.ts).
Vollständige Spec → [`packages/contracts/src/prompts/directions.ts`](packages/contracts/src/prompts/directions.ts).
## Medienerzeugung
@ -584,7 +584,7 @@ Der Chat-/Artifact-Loop steht im Rampenlicht, aber einige weniger sichtbare Fäh
## Anti-AI-Slop-Maschinerie
Die gesamte Maschinerie unten ist das [`huashu-design`](https://github.com/alchaincyf/huashu-design) Playbook, portiert in ODs Prompt Stack und pro Skill über Side-File-Pre-Flight erzwingbar. Lesen Sie [`apps/web/src/prompts/discovery.ts`](apps/web/src/prompts/discovery.ts) für die Live-Formulierung:
Die gesamte Maschinerie unten ist das [`huashu-design`](https://github.com/alchaincyf/huashu-design) Playbook, portiert in ODs Prompt Stack und pro Skill über Side-File-Pre-Flight erzwingbar. Lesen Sie [`packages/contracts/src/prompts/discovery.ts`](packages/contracts/src/prompts/discovery.ts) für die Live-Formulierung:
- **Question form first.** Turn 1 ist nur `<question-form>`: kein Denken, keine Tools, keine Narration. Der Nutzer wählt Defaults mit Radio-Geschwindigkeit.
- **Brand-spec extraction.** Wenn der Nutzer Screenshot oder URL anhängt, führt der Agent ein fünfstufiges Protokoll aus (locate · download · grep hex · codify `brand-spec.md` · vocalise), bevor er CSS schreibt. **Er rät Brandfarben niemals aus Erinnerung.**
@ -659,7 +659,7 @@ Jedes externe Projekt, aus dem dieses Repo etwas übernimmt. Jeder Link führt z
| Projekt | Rolle hier |
|---|---|
| [`Claude Design`][cd] | Das closed-source Produkt, zu dem dieses Repo die Open-Source-Alternative ist. |
| [**`alchaincyf/huashu-design`**](https://github.com/alchaincyf/huashu-design) | Der Design-Philosophie-Kern. Junior-Designer Workflow, 5-step brand-asset protocol, anti-AI-slop checklist, fünfdimensionale Self-Critique und die "5 schools × 20 design philosophies" Bibliothek hinter unserem Direction Picker, alles verdichtet in [`apps/web/src/prompts/discovery.ts`](apps/web/src/prompts/discovery.ts) und [`apps/web/src/prompts/directions.ts`](apps/web/src/prompts/directions.ts). |
| [**`alchaincyf/huashu-design`**](https://github.com/alchaincyf/huashu-design) | Der Design-Philosophie-Kern. Junior-Designer Workflow, 5-step brand-asset protocol, anti-AI-slop checklist, fünfdimensionale Self-Critique und die "5 schools × 20 design philosophies" Bibliothek hinter unserem Direction Picker, alles verdichtet in [`packages/contracts/src/prompts/discovery.ts`](packages/contracts/src/prompts/discovery.ts) und [`packages/contracts/src/prompts/directions.ts`](packages/contracts/src/prompts/directions.ts). |
| [**`op7418/guizang-ppt-skill`**][guizang] | Web-PPT-Skill im Magazinstil, unverändert unter [`skills/guizang-ppt/`](skills/guizang-ppt/) gebündelt, ursprüngliche LICENSE bewahrt. Default für den Deck-Modus. P0/P1/P2 Checklist-Kultur für jeden anderen Skill übernommen. |
| [**`multica-ai/multica`**](https://github.com/multica-ai/multica) | Die daemon + adapter Architektur. PATH-Scan-Agent-Erkennung, lokaler daemon als einziger privilegierter Prozess, Agent-as-teammate Sichtweise. Wir übernehmen das Modell, nicht den Code. |
| [**`OpenCoworkAI/open-codesign`**][ocod] | Die erste Open-Source-Alternative zu Claude Design und unser nächster Peer. Übernommene UX Patterns: streaming-artifact loop, sandboxed-iframe preview (vendored React 18 + Babel), live agent panel (todos + tool calls + interruptible), fünf Exportformate (HTML/PDF/PPTX/ZIP/Markdown), local-first storage hub, `SKILL.md` taste-injection. UX Patterns auf unserer Roadmap: comment-mode surgical edits, AI-emitted tweaks panel. **Wir vendoren [`pi-ai`][piai] bewusst nicht**: open-codesign bündelt es als Agent Runtime; wir delegieren an die CLI, die der Nutzer bereits hat. |

View file

@ -42,7 +42,7 @@ Eso no es "AI tries to design something". Es una IA entrenada por el prompt stac
OD se apoya en cuatro hombros open source:
- [**`alchaincyf/huashu-design`**](https://github.com/alchaincyf/huashu-design): la brújula de filosofía de diseño. El flujo Junior-Designer, el protocolo de marca en 5 pasos, la checklist anti-AI-slop, la autocrítica de 5 dimensiones y la idea de "5 schools × 20 design philosophies" detrás del selector de dirección, todo destilado en [`apps/web/src/prompts/discovery.ts`](apps/web/src/prompts/discovery.ts).
- [**`alchaincyf/huashu-design`**](https://github.com/alchaincyf/huashu-design): la brújula de filosofía de diseño. El flujo Junior-Designer, el protocolo de marca en 5 pasos, la checklist anti-AI-slop, la autocrítica de 5 dimensiones y la idea de "5 schools × 20 design philosophies" detrás del selector de dirección, todo destilado en [`packages/contracts/src/prompts/discovery.ts`](packages/contracts/src/prompts/discovery.ts).
- [**`op7418/guizang-ppt-skill`**](https://github.com/op7418/guizang-ppt-skill): el modo deck. Incluido literalmente bajo [`skills/guizang-ppt/`](skills/guizang-ppt/) con la LICENSE original preservada; layouts magazine-style, hero WebGL y checklists P0/P1/P2.
- [**`OpenCoworkAI/open-codesign`**](https://github.com/OpenCoworkAI/open-codesign): la estrella norte de UX y nuestro par más cercano. La primera alternativa open source a Claude Design. Tomamos prestados su bucle streaming-artifact, el patrón de preview en iframe sandboxed (React 18 + Babel vendorizados), su panel de agente en vivo (todos + tool calls + generación interrumpible) y su lista de cinco formatos de exportación (HTML / PDF / PPTX / ZIP / Markdown). Divergimos deliberadamente en el formato: ellos son una app Electron de escritorio con [`pi-ai`][piai]; nosotros somos una web app + daemon local que delega en tu CLI existente.
- [**`multica-ai/multica`**](https://github.com/multica-ai/multica): la arquitectura daemon-and-runtime. Detección de agentes en `PATH`, el daemon local como único proceso privilegiado y la visión del agente como compañero de equipo.
@ -56,7 +56,7 @@ OD se apoya en cuatro hombros open source:
| **Design systems incluidos** | **129**: 2 starters escritos a mano + 70 sistemas de producto (Linear, Stripe, Vercel, Airbnb, Tesla, Notion, Anthropic, Apple, Cursor, Supabase, Figma, Xiaohongshu, …) desde [`awesome-design-md`][acd2], más 57 design skills desde [`awesome-design-skills`][ads] añadidas directamente bajo `design-systems/` |
| **Skills incluidas** | **31**: 27 en modo `prototype` (web-prototype, saas-landing, dashboard, mobile-app, gamified-app, social-carousel, magazine-poster, dating-web, sprite-animation, motion-frames, critique, tweaks, wireframe-sketch, pm-spec, eng-runbook, finance-report, hr-onboarding, invoice, kanban-board, team-okrs, …) + 4 en modo `deck` (`guizang-ppt` · `simple-deck` · `replit-deck` · `weekly-update`). Agrupadas en el selector por `scenario`: design / marketing / operation / engineering / product / finance / hr / sale / personal. |
| **Generación de medios** | Superficies de imagen · video · audio junto al bucle de diseño. **gpt-image-2** (Azure / OpenAI) para pósters, avatares, infografías y mapas ilustrados · **Seedance 2.0** (ByteDance) para text-to-video e image-to-video cinematográfico de 15s · **HyperFrames** ([heygen-com/hyperframes](https://github.com/heygen-com/hyperframes)) para motion graphics HTML→MP4 (product reveals, tipografía cinética, charts de datos, overlays sociales, logo outros). **93** prompts listos para replicar: 43 gpt-image-2 + 39 Seedance + 11 HyperFrames bajo [`prompt-templates/`](prompt-templates/), con thumbnails de preview y atribución de fuente. La misma superficie de chat que el código; produce chips reales `.mp4` / `.png` en el workspace del proyecto. |
| **Direcciones visuales** | 5 escuelas curadas (Editorial Monocle · Modern Minimal · Warm Soft · Tech Utility · Brutalist Experimental): cada una trae una paleta OKLch determinista + font stack ([`apps/web/src/prompts/directions.ts`](apps/web/src/prompts/directions.ts)) |
| **Direcciones visuales** | 5 escuelas curadas (Editorial Monocle · Modern Minimal · Warm Soft · Tech Utility · Brutalist Experimental): cada una trae una paleta OKLch determinista + font stack ([`packages/contracts/src/prompts/directions.ts`](packages/contracts/src/prompts/directions.ts)) |
| **Frames de dispositivo** | iPhone 15 Pro · Pixel · iPad Pro · MacBook · Browser Chrome: pixel-perfect, compartidos entre skills bajo [`assets/frames/`](assets/frames/) |
| **Runtime de agente** | El daemon local spawnea la CLI en la carpeta del proyecto: el agente recibe `Read`, `Write`, `Bash`, `WebFetch` reales contra un entorno real en disco, con fallbacks de Windows `ENAMETOOLONG` (stdin / prompt-file) en cada adapter |
| **Imports** | Suelta un ZIP exportado desde [Claude Design][cd] en el diálogo de bienvenida: `POST /api/import/claude-design` lo parsea en un proyecto real para que tu agente siga editando donde Anthropic lo dejó |
@ -253,7 +253,7 @@ DISCOVERY directives (turn-1 form, turn-2 brand branch, TodoWrite, 5-dim critiq
+ (deck kind, no skill seed) DECK_FRAMEWORK_DIRECTIVE (nav / counter / scroll / print)
```
Cada capa es componible. Cada capa es un archivo que puedes editar. Lee [`apps/web/src/prompts/system.ts`](apps/web/src/prompts/system.ts) y [`apps/web/src/prompts/discovery.ts`](apps/web/src/prompts/discovery.ts) para ver el contrato real.
Cada capa es componible. Cada capa es un archivo que puedes editar. Lee [`packages/contracts/src/prompts/system.ts`](packages/contracts/src/prompts/system.ts) y [`packages/contracts/src/prompts/discovery.ts`](packages/contracts/src/prompts/discovery.ts) para ver el contrato real.
## Arquitectura
@ -554,7 +554,7 @@ Cuando el usuario no tiene brand spec, el agente emite un segundo formulario con
| Brutalist | Crudo, tipografía oversized, sin sombras, acentos duros | Bloomberg Businessweek · Achtung |
| Soft warm | Generoso, bajo contraste, neutros melocotón | Notion marketing · Apple Health |
Spec completa → [`apps/web/src/prompts/directions.ts`](apps/web/src/prompts/directions.ts).
Spec completa → [`packages/contracts/src/prompts/directions.ts`](packages/contracts/src/prompts/directions.ts).
## Generación de medios
@ -644,7 +644,7 @@ El bucle chat / artifact se lleva el foco, pero ya hay varias capacidades menos
## Maquinaria anti-AI-slop
Todo lo siguiente es el playbook de [`huashu-design`](https://github.com/alchaincyf/huashu-design), portado al prompt-stack de OD y hecho exigible por skill mediante el pre-flight de side files. Lee [`apps/web/src/prompts/discovery.ts`](apps/web/src/prompts/discovery.ts) para ver el texto vivo:
Todo lo siguiente es el playbook de [`huashu-design`](https://github.com/alchaincyf/huashu-design), portado al prompt-stack de OD y hecho exigible por skill mediante el pre-flight de side files. Lee [`packages/contracts/src/prompts/discovery.ts`](packages/contracts/src/prompts/discovery.ts) para ver el texto vivo:
- **Question form first.** El turno 1 es solo `<question-form>`: sin thinking, sin tools, sin narración. El usuario elige defaults a velocidad de radio buttons.
- **Extracción de brand spec.** Cuando el usuario adjunta screenshot o URL, el agente ejecuta un protocolo de cinco pasos (locate · download · grep hex · codify `brand-spec.md` · vocalise) antes de escribir CSS. **Nunca adivina colores de marca de memoria.**
@ -719,7 +719,7 @@ Cada proyecto externo del que este repo toma ideas. Cada link va a la fuente par
| Proyecto | Rol aquí |
|---|---|
| [`Claude Design`][cd] | El producto closed-source del que este repo es alternativa open source. |
| [**`alchaincyf/huashu-design`**](https://github.com/alchaincyf/huashu-design) | Núcleo de filosofía de diseño. Junior-Designer workflow, protocolo de brand assets en 5 pasos, checklist anti-AI-slop, autocrítica de 5 dimensiones y la biblioteca "5 schools × 20 design philosophies" detrás del direction picker, destilado en [`apps/web/src/prompts/discovery.ts`](apps/web/src/prompts/discovery.ts) y [`apps/web/src/prompts/directions.ts`](apps/web/src/prompts/directions.ts). |
| [**`alchaincyf/huashu-design`**](https://github.com/alchaincyf/huashu-design) | Núcleo de filosofía de diseño. Junior-Designer workflow, protocolo de brand assets en 5 pasos, checklist anti-AI-slop, autocrítica de 5 dimensiones y la biblioteca "5 schools × 20 design philosophies" detrás del direction picker, destilado en [`packages/contracts/src/prompts/discovery.ts`](packages/contracts/src/prompts/discovery.ts) y [`packages/contracts/src/prompts/directions.ts`](packages/contracts/src/prompts/directions.ts). |
| [**`op7418/guizang-ppt-skill`**][guizang] | Skill Magazine-web-PPT incluida literalmente bajo [`skills/guizang-ppt/`](skills/guizang-ppt/) con LICENSE original preservada. Default para deck mode. La cultura de checklist P0/P1/P2 se toma para cada otra skill. |
| [**`multica-ai/multica`**](https://github.com/multica-ai/multica) | Arquitectura daemon + adapter. Detección por PATH, daemon local como único proceso privilegiado, visión agent-as-teammate. Adoptamos el modelo; no vendorizamos el código. |
| [**`OpenCoworkAI/open-codesign`**][ocod] | La primera alternativa open source a Claude Design y nuestro par más cercano. Patrones UX adoptados: streaming-artifact loop, sandboxed-iframe preview (React 18 + Babel vendorizados), panel de agente en vivo (todos + tool calls + interruptible), lista de export de cinco formatos (HTML/PDF/PPTX/ZIP/Markdown), hub local-first, taste-injection `SKILL.md` y primer pase de anotaciones comment-mode en preview. Patrones todavía en roadmap: confiabilidad completa de surgical-edit y AI-emitted tweaks panel. **Deliberadamente no vendorizamos [`pi-ai`][piai]**: open-codesign lo incluye como agent runtime; nosotros delegamos en la CLI que ya tenga el usuario. |

View file

@ -43,7 +43,7 @@ Le résultat dépasse lidée dune IA qui tente simplement de faire du desi
OD sappuie sur quatre projets open source :
- [**`alchaincyf/huashu-design`**](https://github.com/alchaincyf/huashu-design), la boussole de design philosophy. Le workflow Junior-Designer, le protocole en 5 étapes pour les assets de marque, la checklist anti-AI-slop, la self-critique en 5 dimensions et lidée « 5 écoles × 20 philosophies design » derrière notre direction picker sont condensés dans [`apps/web/src/prompts/discovery.ts`](apps/web/src/prompts/discovery.ts).
- [**`alchaincyf/huashu-design`**](https://github.com/alchaincyf/huashu-design), la boussole de design philosophy. Le workflow Junior-Designer, le protocole en 5 étapes pour les assets de marque, la checklist anti-AI-slop, la self-critique en 5 dimensions et lidée « 5 écoles × 20 philosophies design » derrière notre direction picker sont condensés dans [`packages/contracts/src/prompts/discovery.ts`](packages/contracts/src/prompts/discovery.ts).
- [**`op7418/guizang-ppt-skill`**](https://github.com/op7418/guizang-ppt-skill), le mode deck. Inclus tel quel sous [`skills/guizang-ppt/`](skills/guizang-ppt/), avec licence originale préservée ; layouts magazine, hero WebGL, checklists P0/P1/P2.
- [**`OpenCoworkAI/open-codesign`**](https://github.com/OpenCoworkAI/open-codesign), notre UX north star et le projet le plus proche. Nous reprenons sa streaming-artifact loop, son pattern de preview en iframe sandboxée (React 18 + Babel vendored), son live agent panel (todos + tool calls + génération interruptible) et ses cinq formats dexport (HTML / PDF / PPTX / ZIP / Markdown). Nous divergeons volontairement sur le format : ils livrent une app desktop Electron avec [`pi-ai`][piai] intégré ; nous sommes une web app + daemon local qui délègue à la CLI déjà installée chez vous.
- [**`multica-ai/multica`**](https://github.com/multica-ai/multica), larchitecture daemon et runtime. Détection des agents dans le `PATH`, daemon local comme seul processus privilégié, vision agent-as-teammate.
@ -57,7 +57,7 @@ OD sappuie sur quatre projets open source :
| **Design Systems intégrés** | Le menu déroulant charge les Design Systems depuis `design-systems/*/DESIGN.md` : starters écrits à la main, product systems importés depuis [`awesome-design-md`][acd2] et design skills normalisés depuis [`awesome-design-skills`][ads]. |
| **Skills intégrés** | Le picker charge les Skills depuis `skills/*/SKILL.md` et les regroupe par `mode` / `scenario` : prototype, deck, image, video, audio, Design System, utility, puis notamment design / marketing / operations / engineering / product / finance / hr / sales / personal. |
| **Génération média** | Les surfaces image, vidéo et audio sont livrées avec la design loop. **gpt-image-2** (Azure / OpenAI) pour posters, avatars, infographies et cartes illustrées ; **Seedance 2.0** (ByteDance) pour du text-to-video et image-to-video cinématique de 15 s ; **HyperFrames** ([heygen-com/hyperframes](https://github.com/heygen-com/hyperframes)) pour des motion graphics HTML→MP4. La galerie [`prompt-templates/`](prompt-templates/) fournit des prompts prêts à reproduire, avec thumbnails et attribution. Même surface de chat que le code ; les sorties deviennent de vrais fichiers `.mp4` / `.png` dans le workspace du projet. |
| **Directions visuelles** | 5 écoles soigneusement sélectionnées (Editorial Monocle · Modern Minimal · Warm Soft · Tech Utility · Brutalist Experimental), chacune avec palette OKLch déterministe + font stack ([`apps/web/src/prompts/directions.ts`](apps/web/src/prompts/directions.ts)) |
| **Directions visuelles** | 5 écoles soigneusement sélectionnées (Editorial Monocle · Modern Minimal · Warm Soft · Tech Utility · Brutalist Experimental), chacune avec palette OKLch déterministe + font stack ([`packages/contracts/src/prompts/directions.ts`](packages/contracts/src/prompts/directions.ts)) |
| **Frames dappareils** | iPhone 15 Pro · Pixel · iPad Pro · MacBook · Browser Chrome, pixel-accurate et partagés entre Skills sous [`assets/frames/`](assets/frames/) |
| **Agent runtime** | Le daemon local lance la CLI dans le dossier projet. Lagent reçoit de vrais `Read`, `Write`, `Bash`, `WebFetch` sur un environnement disque réel, avec fallback Windows `ENAMETOOLONG` (stdin / prompt-file) sur chaque adapter |
| **Imports** | Déposez un ZIP exporté depuis [Claude Design][cd] dans le welcome dialog : `POST /api/import/claude-design` le convertit en vrai projet pour que votre agent continue là où Anthropic sest arrêté |
@ -254,7 +254,7 @@ DISCOVERY directives (formulaire tour 1, branche marque tour 2, TodoWrite, crit
+ (deck kind, no skill seed) DECK_FRAMEWORK_DIRECTIVE (nav / counter / scroll / print)
```
Chaque couche est composable. Chaque couche est un fichier éditable. Lisez [`apps/web/src/prompts/system.ts`](apps/web/src/prompts/system.ts) et [`apps/web/src/prompts/discovery.ts`](apps/web/src/prompts/discovery.ts) pour voir le contrat réel.
Chaque couche est composable. Chaque couche est un fichier éditable. Lisez [`packages/contracts/src/prompts/system.ts`](packages/contracts/src/prompts/system.ts) et [`packages/contracts/src/prompts/discovery.ts`](packages/contracts/src/prompts/discovery.ts) pour voir le contrat réel.
## Architecture
@ -500,7 +500,7 @@ Quand lutilisateur na pas de brand spec, lagent émet un second formula
| Brutalist | Brut, typographie oversized, pas dombres, accents durs | Bloomberg Businessweek · Achtung |
| Soft warm | Généreux, faible contraste, neutres pêche | Notion marketing · Apple Health |
Spec complète → [`apps/web/src/prompts/directions.ts`](apps/web/src/prompts/directions.ts).
Spec complète → [`packages/contracts/src/prompts/directions.ts`](packages/contracts/src/prompts/directions.ts).
## Génération média

View file

@ -44,7 +44,7 @@ Anthropic の [Claude Design][cd]2026-04-17 リリース、Opus 4.7 搭載)
OD は 4 つのオープンソースプロジェクトの上に立っています:
- [**`alchaincyf/huashu-design`**(花叔の画術)](https://github.com/alchaincyf/huashu-design) — デザイン哲学の羅針盤。Junior-Designer ワークフロー、5 ステップのブランドアセットプロトコル、anti-AI-slop チェックリスト、五次元セルフ評価、そしてディレクションピッカーの背後にある「5 流派 × 20 のデザイン哲学」のアイデア — すべて [`apps/web/src/prompts/discovery.ts`](apps/web/src/prompts/discovery.ts) に蒸留されています。
- [**`alchaincyf/huashu-design`**(花叔の画術)](https://github.com/alchaincyf/huashu-design) — デザイン哲学の羅針盤。Junior-Designer ワークフロー、5 ステップのブランドアセットプロトコル、anti-AI-slop チェックリスト、五次元セルフ評価、そしてディレクションピッカーの背後にある「5 流派 × 20 のデザイン哲学」のアイデア — すべて [`packages/contracts/src/prompts/discovery.ts`](packages/contracts/src/prompts/discovery.ts) に蒸留されています。
- [**`op7418/guizang-ppt-skill`**(歸藏の雑誌風 PPT Skill](https://github.com/op7418/guizang-ppt-skill) — Deck モード。[`skills/guizang-ppt/`](skills/guizang-ppt/) 以下にオリジナルのまま同梱、元の LICENSE を保持。雑誌レイアウト、WebGL hero、P0/P1/P2 チェックリスト。
- [**`OpenCoworkAI/open-codesign`**](https://github.com/OpenCoworkAI/open-codesign) — UX の北極星であり、最も近い同類プロジェクト。初のオープンソース Claude-Design 代替。ストリーミング artifact ループ、サンドボックス iframe プレビューReact 18 + Babel 同梱、ライブエージェントパネルtodo + tool calls + 中断可能な生成、5 種類のエクスポート形式リストHTML / PDF / PPTX / ZIP / Markdownを借用。形態では意図的に分岐しています — 彼らは [`pi-ai`][piai] を同梱するデスクトップ Electron アプリ、私たちは既存の CLI に委任する Web アプリ + ローカル daemon です。
- [**`multica-ai/multica`**](https://github.com/multica-ai/multica) — Daemon とランタイムのアーキテクチャ。PATH スキャンによるエージェント検出、ローカル daemon を唯一の特権プロセスとする思想、agent-as-teammate の世界観。
@ -58,7 +58,7 @@ OD は 4 つのオープンソースプロジェクトの上に立っていま
| **組み込み Design System** | **72 種** — 2 つの手書きスターター + [`awesome-design-md`][acd2] からインポートした 70 のプロダクトシステムLinear、Stripe、Vercel、Airbnb、Tesla、Notion、Anthropic、Apple、Cursor、Supabase、Figma、小紅書… |
| **組み込み Skill** | **31 個**`prototype` モード 27 個web-prototype、saas-landing、dashboard、mobile-app、gamified-app、social-carousel、magazine-poster、dating-web、sprite-animation、motion-frames、critique、tweaks、wireframe-sketch、pm-spec、eng-runbook、finance-report、hr-onboarding、invoice、kanban-board、team-okrs…+ `deck` モード 4 個(`guizang-ppt` · `simple-deck` · `replit-deck` · `weekly-update`)。ピッカーは `scenario` でグループ化design / marketing / operation / engineering / product / finance / hr / sale / personal。 |
| **メディア生成** | 画像 · 動画 · 音声サーフェスがデザインループと並走。**gpt-image-2**Azure / OpenAIでポスター・アバター・インフォグラフィック・イラスト都市マップ · **Seedance 2.0**ByteDanceで 15 秒のシネマティック text-to-video / image-to-video · **HyperFrames**[heygen-com/hyperframes](https://github.com/heygen-com/hyperframes))で HTML→MP4 のモーショングラフィック(プロダクトリビール、キネティックタイポグラフィ、データチャート、ソーシャルオーバーレイ、ロゴアウトロ)。**93 件**のすぐ複製できる prompt ギャラリー — 43 gpt-image-2 + 39 Seedance + 11 HyperFrames、すべて [`prompt-templates/`](prompt-templates/) にプレビュー画像と出典付きで配置。Chat の入口はコードと同じ;実体の `.mp4` / `.png` がプロジェクトワークスペースに chip として落ちます。 |
| **ビジュアルディレクション** | 5 つの厳選流派Editorial Monocle · Modern Minimal · Warm Soft · Tech Utility · Brutalist Experimental— 各々に OKLch パレット + フォントスタック付き([`apps/web/src/prompts/directions.ts`](apps/web/src/prompts/directions.ts) |
| **ビジュアルディレクション** | 5 つの厳選流派Editorial Monocle · Modern Minimal · Warm Soft · Tech Utility · Brutalist Experimental— 各々に OKLch パレット + フォントスタック付き([`packages/contracts/src/prompts/directions.ts`](packages/contracts/src/prompts/directions.ts) |
| **デバイスフレーム** | iPhone 15 Pro · Pixel · iPad Pro · MacBook · Browser Chrome — ピクセル単位で正確、Skill 間で共有、[`assets/frames/`](assets/frames/) に集約 |
| **エージェントランタイム** | ローカル daemon がプロジェクトフォルダ内で CLI を spawn — エージェントは実際のディスク上で `Read` / `Write` / `Bash` / `WebFetch` を使用。各 adapter に Windows `ENAMETOOLONG` フォールバックstdin / 一時 prompt ファイル)あり |
| **インポート** | [Claude Design][cd] のエクスポート ZIP をウェルカムダイアログにドロップ — `POST /api/import/claude-design` が実プロジェクトとして展開し、Anthropic の中断箇所からエージェントが編集を続行 |
@ -254,7 +254,7 @@ DISCOVERY ディレクティブ turn-1 フォーム、turn-2 ブランド
+ deck kind かつ Skill seed なし時) DECK_FRAMEWORK_DIRECTIVE nav / counter / scroll / print
```
すべてのレイヤーが組み合わせ可能で、すべてのレイヤーが編集可能なファイルです。実際の契約は [`apps/web/src/prompts/system.ts`](apps/web/src/prompts/system.ts) と [`apps/web/src/prompts/discovery.ts`](apps/web/src/prompts/discovery.ts) で確認できます。
すべてのレイヤーが組み合わせ可能で、すべてのレイヤーが編集可能なファイルです。実際の契約は [`packages/contracts/src/prompts/system.ts`](packages/contracts/src/prompts/system.ts) と [`packages/contracts/src/prompts/discovery.ts`](packages/contracts/src/prompts/discovery.ts) で確認できます。
## アーキテクチャ
@ -491,7 +491,7 @@ open-design/
| Brutalist | 生々しい、巨大タイプ、シャドウなし、鮮烈なアクセント | Bloomberg Businessweek · Achtung |
| Soft warm | おおらか、低コントラスト、ピーチ系ニュートラル | Notion マーケティングページ · Apple Health |
完全な仕様 → [`apps/web/src/prompts/directions.ts`](apps/web/src/prompts/directions.ts)。
完全な仕様 → [`packages/contracts/src/prompts/directions.ts`](packages/contracts/src/prompts/directions.ts)。
## メディア生成
@ -581,7 +581,7 @@ OD はコードで止まりません。`<artifact>` の HTML を生み出すの
## anti-AI-slop 機構
以下の機構はすべて [`huashu-design`](https://github.com/alchaincyf/huashu-design) のプレイブックを OD のプロンプトスタックに移植し、Skill 副ファイルの pre-flight で各 Skill に適用可能にしたものです。実際の文言は [`apps/web/src/prompts/discovery.ts`](apps/web/src/prompts/discovery.ts) を参照:
以下の機構はすべて [`huashu-design`](https://github.com/alchaincyf/huashu-design) のプレイブックを OD のプロンプトスタックに移植し、Skill 副ファイルの pre-flight で各 Skill に適用可能にしたものです。実際の文言は [`packages/contracts/src/prompts/discovery.ts`](packages/contracts/src/prompts/discovery.ts) を参照:
- **まずフォーム。** Turn 1 は `<question-form>` のみ — thinking 禁止、tools 禁止、ナレーション禁止。ユーザーはラジオの速度でデフォルトを選択。
- **ブランドアセットプロトコル。** ユーザーがスクリーンショットや URL を添付した場合、エージェントは 5 ステップのプロトコル(特定 · ダウンロード · grep hex · `brand-spec.md` 作成 · 復唱)を実行してから CSS を書きます。**記憶からブランドカラーを推測することは絶対にありません。**
@ -656,7 +656,7 @@ Daemon 起動時に `PATH` から自動検出。設定不要。ストリーミ
| プロジェクト | 本リポジトリでの役割 |
|---|---|
| [`Claude Design`][cd] | 本リポジトリがオープンソース代替を提供するクローズドソースプロダクト。 |
| [**`alchaincyf/huashu-design`**(花叔の画術)](https://github.com/alchaincyf/huashu-design) | デザイン哲学のコア。Junior-Designer ワークフロー、5 ステップブランドアセットプロトコル、anti-AI-slop チェックリスト、五次元セルフ評価、ディレクションピッカーの背後にある「5 流派 × 20 のデザイン哲学」ライブラリ — すべて [`apps/web/src/prompts/discovery.ts`](apps/web/src/prompts/discovery.ts) と [`apps/web/src/prompts/directions.ts`](apps/web/src/prompts/directions.ts) に蒸留。 |
| [**`alchaincyf/huashu-design`**(花叔の画術)](https://github.com/alchaincyf/huashu-design) | デザイン哲学のコア。Junior-Designer ワークフロー、5 ステップブランドアセットプロトコル、anti-AI-slop チェックリスト、五次元セルフ評価、ディレクションピッカーの背後にある「5 流派 × 20 のデザイン哲学」ライブラリ — すべて [`packages/contracts/src/prompts/discovery.ts`](packages/contracts/src/prompts/discovery.ts) と [`packages/contracts/src/prompts/directions.ts`](packages/contracts/src/prompts/directions.ts) に蒸留。 |
| [**`op7418/guizang-ppt-skill`**(歸藏)][guizang] | Magazine-web-PPT Skill を [`skills/guizang-ppt/`](skills/guizang-ppt/) にそのまま同梱、元の LICENSE 保持。Deck モードのデフォルト。P0/P1/P2 チェックリスト文化を他のすべての Skill に波及。 |
| [**`multica-ai/multica`**](https://github.com/multica-ai/multica) | Daemon + adapter アーキテクチャ。PATH スキャンによるエージェント検出、ローカル daemon を唯一の特権プロセスとする思想、agent-as-teammate の世界観。モデルを採用、コードは vendor せず。 |
| [**`OpenCoworkAI/open-codesign`**][ocod] | 初のオープンソース Claude-Design 代替、最も近い同類。採用済み UX パターン:ストリーミング artifact ループ、サンドボックス iframe プレビューReact 18 + Babel 同梱、ライブエージェントパネルtodo + tool calls + 中断可能、5 種エクスポート形式リストHTML/PDF/PPTX/ZIP/Markdown、ローカルファーストストレージハブ、`SKILL.md` テイスト注入。ロードマップ上の UX パターンコメントモード精密編集、AI 出力 tweaks パネル。**[`pi-ai`][piai] は意図的に vendor していません** — open-codesign はそれをエージェントランタイムとして同梱していますが、私たちはユーザーの既存 CLI に委任します。 |

View file

@ -43,7 +43,7 @@ Anthropic의 [Claude Design][cd](2026-04-17 출시, Opus 4.7 기반)은 LLM이
OD는 네 개의 오픈소스 프로젝트의 어깨 위에 서 있습니다:
- [**`alchaincyf/huashu-design`**](https://github.com/alchaincyf/huashu-design) — 디자인 철학의 나침반. Junior-Designer 워크플로, 5단계 브랜드 에셋 프로토콜, anti-AI-slop 체크리스트, 5차원 자기 검토, 그리고 방향 선택기 뒤의 "5가지 학파 × 20가지 디자인 철학" 아이디어 — 모두 [`apps/web/src/prompts/discovery.ts`](apps/web/src/prompts/discovery.ts)에 녹아들었습니다.
- [**`alchaincyf/huashu-design`**](https://github.com/alchaincyf/huashu-design) — 디자인 철학의 나침반. Junior-Designer 워크플로, 5단계 브랜드 에셋 프로토콜, anti-AI-slop 체크리스트, 5차원 자기 검토, 그리고 방향 선택기 뒤의 "5가지 학파 × 20가지 디자인 철학" 아이디어 — 모두 [`packages/contracts/src/prompts/discovery.ts`](packages/contracts/src/prompts/discovery.ts)에 녹아들었습니다.
- [**`op7418/guizang-ppt-skill`**](https://github.com/op7418/guizang-ppt-skill) — 덱 모드. [`skills/guizang-ppt/`](skills/guizang-ppt/) 아래에 원본 그대로 번들됨, 원 LICENSE 보존; 매거진 레이아웃, WebGL hero, P0/P1/P2 체크리스트.
- [**`OpenCoworkAI/open-codesign`**](https://github.com/OpenCoworkAI/open-codesign) — UX의 북극성이자 가장 가까운 동류. 최초의 오픈소스 Claude-Design 대안. 스트리밍 아티팩트 루프, 샌드박스 iframe 미리보기 패턴(React 18 + Babel 내장), 실시간 에이전트 패널(todos + tool calls + 중단 가능한 생성), 5가지 내보내기 형식(HTML / PDF / PPTX / ZIP / Markdown)을 차용했습니다. 폼 팩터에서는 의도적으로 차별화했습니다 — 그쪽은 [`pi-ai`][piai]를 번들링한 Electron 데스크탑 앱이고, 우리는 에이전트 런타임을 이미 설치된 CLI에 **위임**하는 웹앱 + 로컬 daemon입니다.
- [**`multica-ai/multica`**](https://github.com/multica-ai/multica) — Daemon 및 런타임 아키텍처. PATH 스캔 방식의 에이전트 감지, 단일 특권 프로세스로서의 로컬 daemon, 에이전트-동료 세계관.
@ -57,7 +57,7 @@ OD는 네 개의 오픈소스 프로젝트의 어깨 위에 서 있습니다:
| **내장 디자인 시스템** | **72개** — 2개의 수작업 스타터 + [`awesome-design-md`][acd2]에서 가져온 70개의 제품 시스템(Linear, Stripe, Vercel, Airbnb, Tesla, Notion, Anthropic, Apple, Cursor, Supabase, Figma, Xiaohongshu …) |
| **내장 Skill** | **31개**`prototype` 모드 27개(web-prototype, saas-landing, dashboard, mobile-app, gamified-app, social-carousel, magazine-poster, dating-web, sprite-animation, motion-frames, critique, tweaks, wireframe-sketch, pm-spec, eng-runbook, finance-report, hr-onboarding, invoice, kanban-board, team-okrs …) + `deck` 모드 4개(`guizang-ppt` · `simple-deck` · `replit-deck` · `weekly-update`). picker에서 `scenario`로 그룹화: design / marketing / operation / engineering / product / finance / hr / sale / personal. |
| **미디어 생성** | 이미지 · 비디오 · 오디오 surface가 디자인 루프와 함께 작동합니다. **gpt-image-2**(Azure / OpenAI)로 포스터, 아바타, 인포그래픽, 일러스트 도시 지도 · **Seedance 2.0**(ByteDance)로 15초 시네마틱 text-to-video / image-to-video · **HyperFrames**([heygen-com/hyperframes](https://github.com/heygen-com/hyperframes))로 HTML→MP4 모션 그래픽(제품 리빌, 키네틱 타이포그래피, 데이터 차트, 소셜 오버레이, 로고 아웃트로). **93개**의 즉시 복제 가능한 prompt 갤러리 — 43 gpt-image-2 + 39 Seedance + 11 HyperFrames — 모두 [`prompt-templates/`](prompt-templates/) 아래에 미리보기 썸네일과 출처 표기와 함께 배치. 채팅 입구는 코드와 동일; 실제 `.mp4` / `.png`이 프로젝트 워크스페이스에 chip으로 떨어집니다. |
| **시각적 방향** | 5가지 엄선된 학파(Editorial Monocle · Modern Minimal · Warm Soft · Tech Utility · Brutalist Experimental) — 각각 결정론적 OKLch 팔레트 + 폰트 스택 제공([`apps/web/src/prompts/directions.ts`](apps/web/src/prompts/directions.ts)) |
| **시각적 방향** | 5가지 엄선된 학파(Editorial Monocle · Modern Minimal · Warm Soft · Tech Utility · Brutalist Experimental) — 각각 결정론적 OKLch 팔레트 + 폰트 스택 제공([`packages/contracts/src/prompts/directions.ts`](packages/contracts/src/prompts/directions.ts)) |
| **기기 프레임** | iPhone 15 Pro · Pixel · iPad Pro · MacBook · Browser Chrome — 픽셀 정확도, skill 간 공유, [`assets/frames/`](assets/frames/)에 통합 |
| **에이전트 런타임** | 로컬 daemon이 프로젝트 폴더에서 CLI를 실행 — 에이전트가 실제 디스크 환경에 대한 실제 `Read`, `Write`, `Bash`, `WebFetch` 도구 사용; 모든 어댑터에 Windows `ENAMETOOLONG` 폴백(stdin / 임시 prompt 파일) |
| **임포트** | [Claude Design][cd] 익스포트 ZIP을 환영 다이얼로그에 드롭하면 `POST /api/import/claude-design`이 진짜 프로젝트로 풀어주고, 로컬 에이전트는 Anthropic이 멈춘 지점에서 그대로 편집을 이어받습니다. |
@ -253,7 +253,7 @@ DISCOVERY 지시문 (turn-1 폼, turn-2 브랜드 분기, TodoWrite, 5차원
+ (덱 kind, skill seed 없음) DECK_FRAMEWORK_DIRECTIVE (nav / counter / scroll / print)
```
모든 레이어는 조합 가능합니다. 모든 레이어는 편집 가능한 파일입니다. 실제 계약을 보려면 [`apps/web/src/prompts/system.ts`](apps/web/src/prompts/system.ts)와 [`apps/web/src/prompts/discovery.ts`](apps/web/src/prompts/discovery.ts)를 읽으세요.
모든 레이어는 조합 가능합니다. 모든 레이어는 편집 가능한 파일입니다. 실제 계약을 보려면 [`packages/contracts/src/prompts/system.ts`](packages/contracts/src/prompts/system.ts)와 [`packages/contracts/src/prompts/discovery.ts`](packages/contracts/src/prompts/discovery.ts)를 읽으세요.
## 아키텍처
@ -492,7 +492,7 @@ open-design/
| Brutalist | 날것, 거대한 타입, 그림자 없음, 강한 액센트 | Bloomberg Businessweek · Achtung |
| Soft warm | 여유롭고, 낮은 대비, 복숭아 계열 뉴트럴 | Notion 마케팅 · Apple Health |
전체 스펙 → [`apps/web/src/prompts/directions.ts`](apps/web/src/prompts/directions.ts).
전체 스펙 → [`packages/contracts/src/prompts/directions.ts`](packages/contracts/src/prompts/directions.ts).
## 미디어 생성
@ -582,7 +582,7 @@ OD는 코드에서 끝나지 않습니다. `<artifact>` HTML을 만드는 동일
## Anti-AI-slop 메커니즘
아래의 모든 메커니즘은 [`huashu-design`](https://github.com/alchaincyf/huashu-design) 플레이북을 OD의 프롬프트 스택에 이식하고, 사이드 파일 pre-flight를 통해 skill별로 적용 가능하게 만든 것입니다. 실제 문구는 [`apps/web/src/prompts/discovery.ts`](apps/web/src/prompts/discovery.ts)를 읽으세요:
아래의 모든 메커니즘은 [`huashu-design`](https://github.com/alchaincyf/huashu-design) 플레이북을 OD의 프롬프트 스택에 이식하고, 사이드 파일 pre-flight를 통해 skill별로 적용 가능하게 만든 것입니다. 실제 문구는 [`packages/contracts/src/prompts/discovery.ts`](packages/contracts/src/prompts/discovery.ts)를 읽으세요:
- **질문 폼 우선.** Turn 1은 오직 `<question-form>` — 생각하기 없음, 도구 없음, 내레이션 없음. 사용자는 라디오 속도로 기본값을 선택합니다.
- **브랜드 스펙 추출.** 사용자가 스크린샷이나 URL을 첨부하면, 에이전트는 5단계 프로토콜(위치 파악 · 다운로드 · hex grep · `brand-spec.md` 코드화 · 발성)을 실행한 후 CSS를 작성합니다. **절대 기억에서 브랜드 색상을 추측하지 않습니다.**
@ -657,7 +657,7 @@ daemon 부팅 시 `PATH`에서 자동 감지됩니다. 설정 필요 없음. 스
| 프로젝트 | 역할 |
|---|---|
| [`Claude Design`][cd] | 이 저장소가 오픈소스 대안을 제공하는 클로즈드 소스 제품. |
| [**`alchaincyf/huashu-design`**](https://github.com/alchaincyf/huashu-design) | 디자인 철학 핵심. Junior-Designer 워크플로, 5단계 브랜드 에셋 프로토콜, anti-AI-slop 체크리스트, 5차원 자기 검토, 그리고 방향 선택기 뒤의 "5가지 학파 × 20가지 디자인 철학" 라이브러리 — 모두 [`apps/web/src/prompts/discovery.ts`](apps/web/src/prompts/discovery.ts)와 [`apps/web/src/prompts/directions.ts`](apps/web/src/prompts/directions.ts)에 녹아들었습니다. |
| [**`alchaincyf/huashu-design`**](https://github.com/alchaincyf/huashu-design) | 디자인 철학 핵심. Junior-Designer 워크플로, 5단계 브랜드 에셋 프로토콜, anti-AI-slop 체크리스트, 5차원 자기 검토, 그리고 방향 선택기 뒤의 "5가지 학파 × 20가지 디자인 철학" 라이브러리 — 모두 [`packages/contracts/src/prompts/discovery.ts`](packages/contracts/src/prompts/discovery.ts)와 [`packages/contracts/src/prompts/directions.ts`](packages/contracts/src/prompts/directions.ts)에 녹아들었습니다. |
| [**`op7418/guizang-ppt-skill`**][guizang] | [`skills/guizang-ppt/`](skills/guizang-ppt/) 아래에 원본 그대로 번들된 Magazine-web-PPT skill, 원 LICENSE 보존. 덱 모드 기본. P0/P1/P2 체크리스트 문화는 다른 모든 skill에도 차용됩니다. |
| [**`multica-ai/multica`**](https://github.com/multica-ai/multica) | Daemon + 어댑터 아키텍처. PATH 스캔 에이전트 감지, 단일 특권 프로세스로서의 로컬 daemon, 에이전트-동료 세계관. 모델을 채용했지만 코드는 vendor하지 않습니다. |
| [**`OpenCoworkAI/open-codesign`**][ocod] | 최초의 오픈소스 Claude-Design 대안이자 가장 가까운 동류. 채택된 UX 패턴: 스트리밍 아티팩트 루프, 샌드박스 iframe 미리보기(React 18 + Babel 내장), 실시간 에이전트 패널(todos + tool calls + 중단 가능), 5가지 내보내기 형식(HTML/PDF/PPTX/ZIP/Markdown), 로컬 우선 designs 허브, `SKILL.md` 취향 주입. 로드맵의 UX 패턴: 코멘트 모드 수술적 편집, AI 제안 트윅 패널. **[`pi-ai`][piai]는 의도적으로 vendor하지 않습니다** — open-codesign은 이를 에이전트 런타임으로 번들링하지만 우리는 사용자가 이미 가진 CLI에 위임합니다. |

View file

@ -44,7 +44,7 @@ That's not "AI tries to design something". That's an AI that has been trained, b
OD stands on four open-source shoulders:
- [**`alchaincyf/huashu-design`**](https://github.com/alchaincyf/huashu-design) — the design-philosophy compass. Junior-Designer workflow, the 5-step brand-asset protocol, the anti-AI-slop checklist, the 5-dimensional self-critique, and the "5 schools × 20 design philosophies" idea behind our direction picker — all distilled into [`apps/web/src/prompts/discovery.ts`](apps/web/src/prompts/discovery.ts).
- [**`alchaincyf/huashu-design`**](https://github.com/alchaincyf/huashu-design) — the design-philosophy compass. Junior-Designer workflow, the 5-step brand-asset protocol, the anti-AI-slop checklist, the 5-dimensional self-critique, and the "5 schools × 20 design philosophies" idea behind our direction picker — all distilled into [`packages/contracts/src/prompts/discovery.ts`](packages/contracts/src/prompts/discovery.ts).
- [**`op7418/guizang-ppt-skill`**](https://github.com/op7418/guizang-ppt-skill) — the deck mode. Bundled verbatim under [`skills/guizang-ppt/`](skills/guizang-ppt/) with original LICENSE preserved; magazine-style layouts, WebGL hero, P0/P1/P2 checklists.
- [**`OpenCoworkAI/open-codesign`**](https://github.com/OpenCoworkAI/open-codesign) — the UX north star and our closest peer. The first open-source Claude-Design alternative. We borrow its streaming-artifact loop, its sandboxed-iframe preview pattern (vendored React 18 + Babel), its live agent panel (todos + tool calls + interruptible generation), and its five-format export list (HTML / PDF / PPTX / ZIP / Markdown). We deliberately diverge on form factor — they are a desktop Electron app bundling [`pi-ai`][piai]; we are a web app + local daemon that delegates to your existing CLI.
- [**`multica-ai/multica`**](https://github.com/multica-ai/multica) — the daemon-and-runtime architecture. PATH-scan agent detection, the local daemon as the only privileged process, the agent-as-teammate worldview.
@ -58,7 +58,7 @@ OD stands on four open-source shoulders:
| **Design systems built-in** | **129** — 2 hand-authored starters + 70 product systems (Linear, Stripe, Vercel, Airbnb, Tesla, Notion, Anthropic, Apple, Cursor, Supabase, Figma, Xiaohongshu, …) from [`awesome-design-md`][acd2], plus 57 design skills from [`awesome-design-skills`][ads] added directly under `design-systems/` |
| **Skills built-in** | **31** — 27 in `prototype` mode (web-prototype, saas-landing, dashboard, mobile-app, gamified-app, social-carousel, magazine-poster, dating-web, sprite-animation, motion-frames, critique, tweaks, wireframe-sketch, pm-spec, eng-runbook, finance-report, hr-onboarding, invoice, kanban-board, team-okrs, …) + 4 in `deck` mode (`guizang-ppt` · `simple-deck` · `replit-deck` · `weekly-update`). Grouped in the picker by `scenario`: design / marketing / operation / engineering / product / finance / hr / sale / personal. |
| **Media generation** | Image · video · audio surfaces ship alongside the design loop. **gpt-image-2** (Azure / OpenAI) for posters, avatars, infographics, illustrated maps · **Seedance 2.0** (ByteDance) for cinematic 15s text-to-video and image-to-video · **HyperFrames** ([heygen-com/hyperframes](https://github.com/heygen-com/hyperframes)) for HTML→MP4 motion graphics (product reveals, kinetic typography, data charts, social overlays, logo outros). **93** ready-to-replicate prompts gallery — 43 gpt-image-2 + 39 Seedance + 11 HyperFrames — under [`prompt-templates/`](prompt-templates/), with preview thumbnails and source attribution. Same chat surface as code; outputs a real `.mp4` / `.png` chip into the project workspace. |
| **Visual directions** | 5 curated schools (Editorial Monocle · Modern Minimal · Warm Soft · Tech Utility · Brutalist Experimental) — each ships a deterministic OKLch palette + font stack ([`apps/web/src/prompts/directions.ts`](apps/web/src/prompts/directions.ts)) |
| **Visual directions** | 5 curated schools (Editorial Monocle · Modern Minimal · Warm Soft · Tech Utility · Brutalist Experimental) — each ships a deterministic OKLch palette + font stack ([`packages/contracts/src/prompts/directions.ts`](packages/contracts/src/prompts/directions.ts)) |
| **Device frames** | iPhone 15 Pro · Pixel · iPad Pro · MacBook · Browser Chrome — pixel-accurate, shared across skills under [`assets/frames/`](assets/frames/) |
| **Agent runtime** | Local daemon spawns the CLI in your project folder — agent gets real `Read`, `Write`, `Bash`, `WebFetch` against a real on-disk environment, with Windows `ENAMETOOLONG` fallbacks (stdin / prompt-file) on every adapter |
| **Imports** | Drop a [Claude Design][cd] export ZIP onto the welcome dialog — `POST /api/import/claude-design` parses it into a real project so your agent can keep editing where Anthropic left off |
@ -221,7 +221,7 @@ Adding a skill takes one folder. Read [`docs/skills-protocol.md`](docs/skills-pr
### 1 · We don't ship an agent. Yours is good enough.
The daemon scans your `PATH` for [`claude`](https://docs.anthropic.com/en/docs/claude-code), [`codex`](https://github.com/openai/codex), `devin`, [`cursor-agent`](https://www.cursor.com/cli), [`gemini`](https://github.com/google-gemini/gemini-cli), [`opencode`](https://opencode.ai/), [`qwen`](https://github.com/QwenLM/qwen-code), `qodercli`, [`copilot`](https://github.com/features/copilot/cli), `hermes`, `kimi`, [`pi`](https://github.com/badlogic/pi-mono/tree/main/packages/coding-agent), [`kiro-cli`](https://kiro.dev), `kilo`, [`vibe-acp`](https://github.com/mistralai/mistral-vibe), and `deepseek` on startup. Whichever ones it finds become candidate design engines — driven over stdio with one adapter per CLI, swappable from the model picker. Inspired by [`multica`](https://github.com/multica-ai/multica) and [`cc-switch`](https://github.com/farion1231/cc-switch). No CLI installed? The API mode is the same pipeline minus the spawn — choose Anthropic, OpenAI-compatible, Azure OpenAI, or Google Gemini and the daemon forwards normalized SSE chunks back, with loopback / link-local / RFC1918 destinations rejected at the edge.
The daemon scans your `PATH` for [`claude`](https://docs.anthropic.com/en/docs/claude-code), [`codex`](https://github.com/openai/codex), `devin`, [`cursor-agent`](https://www.cursor.com/cli), [`gemini`](https://github.com/google-gemini/gemini-cli), [`opencode`](https://opencode.ai/), [`qwen`](https://github.com/QwenLM/qwen-code), `qodercli`, [`copilot`](https://github.com/features/copilot/cli), `hermes`, `kimi`, [`pi`](https://github.com/badlogic/pi-mono/tree/main/packages/coding-agent), [`kiro-cli`](https://kiro.dev), `kilo`, [`vibe-acp`](https://github.com/mistralai/mistral-vibe), and `deepseek` on startup. Whichever ones it finds become candidate design engines — driven over stdio with one adapter per CLI, swappable from the model picker. Inspired by [`multica`](https://github.com/multica-ai/multica) and [`cc-switch`](https://github.com/farion1231/cc-switch). No CLI installed? The API mode is the same pipeline minus the spawn — choose Anthropic, OpenAI-compatible, Azure OpenAI, or Google Gemini and the daemon forwards normalized SSE chunks back. Loopback is allowed for local LLM providers such as Ollama and LM Studio; non-loopback private, link-local, CGNAT, multicast, reserved, and redirect targets are rejected at the daemon edge.
### 2 · Skills are files, not plugins.
@ -255,7 +255,7 @@ DISCOVERY directives (turn-1 form, turn-2 brand branch, TodoWrite, 5-dim critiq
+ (deck kind, no skill seed) DECK_FRAMEWORK_DIRECTIVE (nav / counter / scroll / print)
```
Every layer is composable. Every layer is a file you can edit. Read [`apps/web/src/prompts/system.ts`](apps/web/src/prompts/system.ts) and [`apps/web/src/prompts/discovery.ts`](apps/web/src/prompts/discovery.ts) to see the actual contract.
Every layer is composable. Every layer is a file you can edit. Read [`packages/contracts/src/prompts/system.ts`](packages/contracts/src/prompts/system.ts) and [`packages/contracts/src/prompts/discovery.ts`](packages/contracts/src/prompts/discovery.ts) to see the actual contract.
## Architecture
@ -293,7 +293,7 @@ Every layer is composable. Every layer is a file you can edit. Read [`apps/web/s
| Frontend | Next.js 16 App Router + React 18 + TypeScript, Vercel-deployable |
| Daemon | Node 24 · Express · SSE streaming · `better-sqlite3`; tables: `projects` · `conversations` · `messages` · `tabs` · `templates` |
| Agent transport | `child_process.spawn`; typed-event parsers for `claude-stream-json` (Claude Code), `qoder-stream-json` (Qoder CLI), `copilot-stream-json` (Copilot), `json-event-stream` per-CLI parsers (Codex / Gemini / OpenCode / Cursor Agent), `acp-json-rpc` (Devin / Hermes / Kimi / Kiro / Kilo / Mistral Vibe via Agent Client Protocol), `pi-rpc` (Pi via stdio JSON-RPC), `plain` (Qwen Code / DeepSeek TUI) |
| BYOK proxy | `POST /api/proxy/{anthropic,openai,azure,google}/stream` → provider-specific upstream APIs, normalized `delta/end/error` SSE; rejects loopback / link-local / RFC1918 hosts at the daemon edge |
| BYOK proxy | `POST /api/proxy/{anthropic,openai,azure,google}/stream` → provider-specific upstream APIs, normalized `delta/end/error` SSE; allows loopback local LLM providers, rejects non-loopback private/link-local/CGNAT/multicast/reserved hosts, and disables upstream redirects at the daemon edge |
| Storage | Plain files in `.od/projects/<id>/` + SQLite at `.od/app.sqlite` + credentials at `.od/media-config.json` (gitignored, auto-created). `OD_DATA_DIR=<dir>` relocates all daemon data (used for test isolation and read-only-install setups); `OD_MEDIA_CONFIG_DIR=<dir>` further narrows the override to just `media-config.json` for setups that want to keep API keys outside the data dir |
| Preview | Sandboxed iframe via `srcdoc` + per-skill `<artifact>` parser ([`apps/web/src/artifacts/parser.ts`](apps/web/src/artifacts/parser.ts)) |
| Export | HTML (inline assets) · PDF (browser print, deck-aware) · PPTX (agent-driven via skill) · ZIP (archiver) · Markdown |
@ -706,7 +706,7 @@ When the user has no brand spec, the agent emits a second form with five curated
| Brutalist | Raw, oversized type, no shadows, harsh accents | Bloomberg Businessweek · Achtung |
| Soft warm | Generous, low contrast, peachy neutrals | Notion marketing · Apple Health |
Full spec → [`apps/web/src/prompts/directions.ts`](apps/web/src/prompts/directions.ts).
Full spec → [`packages/contracts/src/prompts/directions.ts`](packages/contracts/src/prompts/directions.ts).
## Media generation
@ -786,7 +786,7 @@ Pattern is the same as the rest: pick a template, edit the brief, send. The agen
The chat / artifact loop gets the spotlight, but a handful of less-visible capabilities are already wired and worth knowing before you compare OD to anything else:
- **Claude Design ZIP import.** Drop an export from claude.ai onto the welcome dialog. `POST /api/import/claude-design` extracts it into a real `.od/projects/<id>/`, opens the entry file as a tab, and stages a continue-where-Anthropic-left-off prompt for your local agent. No re-prompting, no "ask the model to re-create what we just had". ([`apps/daemon/src/server.ts`](apps/daemon/src/server.ts) — `/api/import/claude-design`)
- **Multi-provider BYOK proxy.** `POST /api/proxy/{anthropic,openai,azure,google}/stream` takes `{ baseUrl, apiKey, model, messages }`, builds the provider-specific upstream request, normalizes SSE chunks into `delta/end/error`, and rejects loopback / link-local / RFC1918 destinations to head off SSRF. OpenAI-compatible covers OpenAI, Azure AI Foundry `/openai/v1`, DeepSeek, Groq, MiMo, OpenRouter, and self-hosted vLLM; Azure OpenAI adds deployment URL + `api-version`; Google uses Gemini `:streamGenerateContent`.
- **Multi-provider BYOK proxy.** `POST /api/proxy/{anthropic,openai,azure,google}/stream` takes `{ baseUrl, apiKey, model, messages }`, builds the provider-specific upstream request, normalizes SSE chunks into `delta/end/error`, and allows loopback local LLM providers while rejecting non-loopback private, link-local, CGNAT, multicast, reserved, and redirect targets to head off SSRF. OpenAI-compatible covers OpenAI, Azure AI Foundry `/openai/v1`, DeepSeek, Groq, MiMo, OpenRouter, Ollama, LM Studio, and self-hosted vLLM; Azure OpenAI adds deployment URL + `api-version`; Google uses Gemini `:streamGenerateContent`.
- **User-saved templates.** Once you like a render, `POST /api/templates` snapshots the HTML + metadata into the SQLite `templates` table. The next project picks it from a "your templates" row in the picker — same surface as the shipped 31, but yours.
- **Tab persistence.** Every project remembers its open files and active tab in the `tabs` table. Reopen the project tomorrow and the workspace looks exactly the way you left it.
- **Artifact lint API.** `POST /api/artifacts/lint` runs structural checks on a generated artifact (broken `<artifact>` framing, missing required side files, stale palette tokens) and returns findings the agent can read back into its next turn. The five-dim self-critique uses this to ground its score in real evidence, not vibes.
@ -796,7 +796,7 @@ The chat / artifact loop gets the spotlight, but a handful of less-visible capab
## Anti-AI-slop machinery
The whole machinery below is the [`huashu-design`](https://github.com/alchaincyf/huashu-design) playbook, ported into OD's prompt-stack and made enforceable per-skill via the side-file pre-flight. Read [`apps/web/src/prompts/discovery.ts`](apps/web/src/prompts/discovery.ts) for the live wording:
The whole machinery below is the [`huashu-design`](https://github.com/alchaincyf/huashu-design) playbook, ported into OD's prompt-stack and made enforceable per-skill via the side-file pre-flight. Read [`packages/contracts/src/prompts/discovery.ts`](packages/contracts/src/prompts/discovery.ts) for the live wording:
- **Question form first.** Turn 1 is `<question-form>` only — no thinking, no tools, no narration. The user chooses defaults at radio speed.
- **Brand-spec extraction.** When the user attaches a screenshot or URL, the agent runs a five-step protocol (locate · download · grep hex · codify `brand-spec.md` · vocalise) before writing CSS. **Never guesses brand colors from memory.**
@ -860,7 +860,7 @@ Auto-detected from `PATH` on daemon boot. No config required. Streaming dispatch
| [Mistral Vibe CLI](https://github.com/mistralai/mistral-vibe) | `vibe-acp` | `acp-json-rpc` | `vibe-acp` |
| DeepSeek TUI | `deepseek` | `plain` (raw stdout chunks) | `deepseek exec --auto [--model …] <prompt>` (prompt as positional arg) |
| [Pi](https://github.com/badlogic/pi-mono/tree/main/packages/coding-agent) | `pi` | `pi-rpc` (stdio JSON-RPC) | `pi --mode rpc [--model …] [--thinking …]` (prompt sent as RPC `prompt` command) |
| **Multi-provider BYOK** | n/a | SSE normalization | `POST /api/proxy/{provider}/stream` → Anthropic / OpenAI-compatible / Azure OpenAI / Gemini; SSRF-guarded against loopback / link-local / RFC1918 |
| **Multi-provider BYOK** | n/a | SSE normalization | `POST /api/proxy/{provider}/stream` → Anthropic / OpenAI-compatible / Azure OpenAI / Gemini; SSRF-guarded with loopback local providers allowed, non-loopback internal ranges blocked, and upstream redirects disabled |
Adding a new CLI is one entry in [`apps/daemon/src/agents.ts`](apps/daemon/src/agents.ts). Streaming format is one of `claude-stream-json`, `qoder-stream-json`, `copilot-stream-json`, `json-event-stream` (with a per-CLI `eventParser`), `acp-json-rpc`, `pi-rpc`, or `plain`.
@ -871,7 +871,7 @@ Every external project this repo borrows from. Each link goes to the source so y
| Project | Role here |
|---|---|
| [`Claude Design`][cd] | The closed-source product this repo is the open-source alternative to. |
| [**`alchaincyf/huashu-design`**](https://github.com/alchaincyf/huashu-design) | The design-philosophy core. Junior-Designer workflow, the 5-step brand-asset protocol, anti-AI-slop checklist, 5-dimensional self-critique, and the "5 schools × 20 design philosophies" library behind our direction picker — all distilled into [`apps/web/src/prompts/discovery.ts`](apps/web/src/prompts/discovery.ts) and [`apps/web/src/prompts/directions.ts`](apps/web/src/prompts/directions.ts). |
| [**`alchaincyf/huashu-design`**](https://github.com/alchaincyf/huashu-design) | The design-philosophy core. Junior-Designer workflow, the 5-step brand-asset protocol, anti-AI-slop checklist, 5-dimensional self-critique, and the "5 schools × 20 design philosophies" library behind our direction picker — all distilled into [`packages/contracts/src/prompts/discovery.ts`](packages/contracts/src/prompts/discovery.ts) and [`packages/contracts/src/prompts/directions.ts`](packages/contracts/src/prompts/directions.ts). |
| [**`op7418/guizang-ppt-skill`**][guizang] | Magazine-web-PPT skill bundled verbatim under [`skills/guizang-ppt/`](skills/guizang-ppt/) with original LICENSE preserved. Default for deck mode. P0/P1/P2 checklist culture borrowed for every other skill. |
| [**`multica-ai/multica`**](https://github.com/multica-ai/multica) | The daemon + adapter architecture. PATH-scan agent detection, local daemon as the only privileged process, agent-as-teammate worldview. We adopt the model; we do not vendor the code. |
| [**`OpenCoworkAI/open-codesign`**][ocod] | The first open-source Claude-Design alternative and our closest peer. UX patterns adopted: streaming-artifact loop, sandboxed-iframe preview (vendored React 18 + Babel), live agent panel (todos + tool calls + interruptible), five-format export list (HTML/PDF/PPTX/ZIP/Markdown), local-first storage hub, `SKILL.md` taste-injection, and the first pass of comment-mode preview annotations. UX patterns still on our roadmap: full surgical-edit reliability and AI-emitted tweaks panel. **We deliberately do not vendor [`pi-ai`][piai]** — open-codesign bundles it as the agent runtime; we delegate to whichever CLI the user already has. |

View file

@ -43,7 +43,7 @@ Isso não é "IA tentando desenhar algo". É uma IA que foi treinada, pela pilha
OD se apoia em quatro ombros open-source:
- [**`alchaincyf/huashu-design`**](https://github.com/alchaincyf/huashu-design) — a bússola da filosofia de design. Workflow Junior-Designer, protocolo de 5 passos para asset de marca, checklist anti-AI-slop, autocrítica em 5 dimensões e a ideia "5 escolas × 20 filosofias de design" por trás do nosso direction picker — tudo destilado em [`apps/web/src/prompts/discovery.ts`](apps/web/src/prompts/discovery.ts).
- [**`alchaincyf/huashu-design`**](https://github.com/alchaincyf/huashu-design) — a bússola da filosofia de design. Workflow Junior-Designer, protocolo de 5 passos para asset de marca, checklist anti-AI-slop, autocrítica em 5 dimensões e a ideia "5 escolas × 20 filosofias de design" por trás do nosso direction picker — tudo destilado em [`packages/contracts/src/prompts/discovery.ts`](packages/contracts/src/prompts/discovery.ts).
- [**`op7418/guizang-ppt-skill`**](https://github.com/op7418/guizang-ppt-skill) — o modo deck. Empacotado literalmente sob [`skills/guizang-ppt/`](skills/guizang-ppt/) com o LICENSE original preservado; layouts estilo revista, hero WebGL, checklists P0/P1/P2.
- [**`OpenCoworkAI/open-codesign`**](https://github.com/OpenCoworkAI/open-codesign) — a estrela-guia de UX e nosso peer mais próximo. A primeira alternativa open-source ao Claude Design. Pegamos o loop de streaming-artifact dele, o padrão de preview em iframe sandboxed (React 18 + Babel vendored), o painel de agente ao vivo (todos + tool calls + geração interruptível) e a lista de cinco formatos de export (HTML / PDF / PPTX / ZIP / Markdown). Divergimos de propósito no form factor — eles são um app desktop Electron com [`pi-ai`][piai] embutido; nós somos um web app + daemon local que delega ao seu CLI já existente.
- [**`multica-ai/multica`**](https://github.com/multica-ai/multica) — a arquitetura de daemon-and-runtime. Detecção de agente por scan de PATH, daemon local como único processo privilegiado, visão de mundo agente-como-time.
@ -57,7 +57,7 @@ OD se apoia em quatro ombros open-source:
| **Design systems built-in** | **129** — 2 starters escritos à mão + 70 sistemas de produto (Linear, Stripe, Vercel, Airbnb, Tesla, Notion, Anthropic, Apple, Cursor, Supabase, Figma, Xiaohongshu, …) de [`awesome-design-md`][acd2], mais 57 design skills de [`awesome-design-skills`][ads] adicionados direto em `design-systems/` |
| **Skills built-in** | **31** — 27 em modo `prototype` (web-prototype, saas-landing, dashboard, mobile-app, gamified-app, social-carousel, magazine-poster, dating-web, sprite-animation, motion-frames, critique, tweaks, wireframe-sketch, pm-spec, eng-runbook, finance-report, hr-onboarding, invoice, kanban-board, team-okrs, …) + 4 em modo `deck` (`guizang-ppt` · `simple-deck` · `replit-deck` · `weekly-update`). Agrupadas no picker por `scenario`: design / marketing / operation / engineering / product / finance / hr / sale / personal. |
| **Geração de mídia** | Imagem · vídeo · áudio entregues lado a lado com o loop de design. **gpt-image-2** (Azure / OpenAI) para pôsteres, avatares, infográficos, mapas ilustrados · **Seedance 2.0** (ByteDance) para texto-para-vídeo cinematográfico de 15s e imagem-para-vídeo · **HyperFrames** ([heygen-com/hyperframes](https://github.com/heygen-com/hyperframes)) para motion graphics HTML→MP4 (revelações de produto, kinetic typography, gráficos de dados, overlays sociais, logo outros). **93** prompts prontos para replicar — 43 gpt-image-2 + 39 Seedance + 11 HyperFrames — em [`prompt-templates/`](prompt-templates/), com thumbnails de preview e atribuição da fonte. Mesma superfície de chat do código; saída é um `.mp4` / `.png` real entrando no workspace do projeto. |
| **Direções visuais** | 5 escolas curadas (Editorial Monocle · Modern Minimal · Warm Soft · Tech Utility · Brutalist Experimental) — cada uma trazendo paleta OKLch determinística + font stack ([`apps/web/src/prompts/directions.ts`](apps/web/src/prompts/directions.ts)) |
| **Direções visuais** | 5 escolas curadas (Editorial Monocle · Modern Minimal · Warm Soft · Tech Utility · Brutalist Experimental) — cada uma trazendo paleta OKLch determinística + font stack ([`packages/contracts/src/prompts/directions.ts`](packages/contracts/src/prompts/directions.ts)) |
| **Frames de dispositivo** | iPhone 15 Pro · Pixel · iPad Pro · MacBook · Browser Chrome — pixel-accurate, compartilhados entre skills sob [`assets/frames/`](assets/frames/) |
| **Runtime de agente** | Daemon local sobe o CLI dentro da pasta do seu projeto — agente recebe `Read`, `Write`, `Bash`, `WebFetch` reais contra um ambiente real em disco, com fallbacks de Windows `ENAMETOOLONG` (stdin / arquivo de prompt) em todos os adapters |
| **Imports** | Solte um ZIP exportado do [Claude Design][cd] no welcome dialog — `POST /api/import/claude-design` parseia para um projeto real, então seu agente continua editando de onde a Anthropic parou |
@ -254,7 +254,7 @@ DISCOVERY directives (turn-1 form, turn-2 brand branch, TodoWrite, 5-dim critiq
+ (deck kind, no skill seed) DECK_FRAMEWORK_DIRECTIVE (nav / counter / scroll / print)
```
Toda camada é compositável. Toda camada é um arquivo que dá pra editar. Leia [`apps/web/src/prompts/system.ts`](apps/web/src/prompts/system.ts) e [`apps/web/src/prompts/discovery.ts`](apps/web/src/prompts/discovery.ts) para ver o contrato real.
Toda camada é compositável. Toda camada é um arquivo que dá pra editar. Leia [`packages/contracts/src/prompts/system.ts`](packages/contracts/src/prompts/system.ts) e [`packages/contracts/src/prompts/discovery.ts`](packages/contracts/src/prompts/discovery.ts) para ver o contrato real.
## Arquitetura
@ -497,7 +497,7 @@ Quando o usuário não tem brand spec, o agente emite um segundo formulário com
| Brutalist | Cru, tipografia gigante, sem sombra, acentos duros | Bloomberg Businessweek · Achtung |
| Soft warm | Generoso, baixo contraste, neutros pessegos | Marketing da Notion · Apple Health |
Spec completa → [`apps/web/src/prompts/directions.ts`](apps/web/src/prompts/directions.ts).
Spec completa → [`packages/contracts/src/prompts/directions.ts`](packages/contracts/src/prompts/directions.ts).
## Geração de mídia
@ -587,7 +587,7 @@ O loop chat / artifact é o destaque, mas algumas capacidades menos visíveis j
## Maquinário anti-AI-slop
Toda a maquinaria abaixo é o playbook do [`huashu-design`](https://github.com/alchaincyf/huashu-design), portado para a pilha de prompt do OD e exigível por skill via o pre-flight de side files. Leia [`apps/web/src/prompts/discovery.ts`](apps/web/src/prompts/discovery.ts) para o texto vivo:
Toda a maquinaria abaixo é o playbook do [`huashu-design`](https://github.com/alchaincyf/huashu-design), portado para a pilha de prompt do OD e exigível por skill via o pre-flight de side files. Leia [`packages/contracts/src/prompts/discovery.ts`](packages/contracts/src/prompts/discovery.ts) para o texto vivo:
- **Formulário de perguntas primeiro.** O turn 1 é só `<question-form>` — sem pensar, sem tools, sem narração. O usuário escolhe defaults na velocidade de um radio.
- **Extração de brand-spec.** Quando o usuário anexa um screenshot ou URL, o agente roda um protocolo de cinco passos (localizar · baixar · grep hex · codificar `brand-spec.md` · vocalizar) antes de escrever CSS. **Nunca chuta cores de marca de memória.**
@ -662,7 +662,7 @@ Todo projeto externo do qual este repo emprestou. Cada link aponta para a fonte
| Projeto | Papel aqui |
|---|---|
| [`Claude Design`][cd] | O produto closed-source ao qual este repo é alternativa open-source. |
| [**`alchaincyf/huashu-design`**](https://github.com/alchaincyf/huashu-design) | O núcleo de filosofia de design. Workflow Junior-Designer, protocolo de 5 passos para asset de marca, checklist anti-AI-slop, autocrítica em 5 dimensões e a biblioteca "5 escolas × 20 filosofias de design" por trás do nosso direction picker — tudo destilado em [`apps/web/src/prompts/discovery.ts`](apps/web/src/prompts/discovery.ts) e [`apps/web/src/prompts/directions.ts`](apps/web/src/prompts/directions.ts). |
| [**`alchaincyf/huashu-design`**](https://github.com/alchaincyf/huashu-design) | O núcleo de filosofia de design. Workflow Junior-Designer, protocolo de 5 passos para asset de marca, checklist anti-AI-slop, autocrítica em 5 dimensões e a biblioteca "5 escolas × 20 filosofias de design" por trás do nosso direction picker — tudo destilado em [`packages/contracts/src/prompts/discovery.ts`](packages/contracts/src/prompts/discovery.ts) e [`packages/contracts/src/prompts/directions.ts`](packages/contracts/src/prompts/directions.ts). |
| [**`op7418/guizang-ppt-skill`**][guizang] | Skill magazine-web-PPT bundled literalmente sob [`skills/guizang-ppt/`](skills/guizang-ppt/) com LICENSE original preservado. Default do deck mode. Cultura de checklist P0/P1/P2 emprestada para todas as outras skills. |
| [**`multica-ai/multica`**](https://github.com/multica-ai/multica) | A arquitetura de daemon + adapter. Detecção de agente por scan de PATH, daemon local como único processo privilegiado, visão de mundo agente-como-time. Adotamos o modelo; não vendoramos o código. |
| [**`OpenCoworkAI/open-codesign`**][ocod] | A primeira alternativa open-source ao Claude Design e nosso peer mais próximo. Padrões UX adotados: loop streaming-artifact, preview em iframe sandboxed (React 18 + Babel vendored), painel de agente ao vivo (todos + tool calls + interruptível), lista de cinco formatos de export (HTML/PDF/PPTX/ZIP/Markdown), hub de storage local-first, injeção de gosto via `SKILL.md` e a primeira passada de anotações de preview em modo comentário. Padrões UX ainda no nosso roadmap: confiabilidade plena de edição cirúrgica e painel de tweaks emitido pela IA. **Deliberadamente não vendoramos [`pi-ai`][piai]** — o open-codesign embute como runtime de agente; nós delegamos para o CLI que o usuário já tem. |

View file

@ -43,7 +43,7 @@ Anthropic [Claude Design][cd] (выпущен 2026-04-17, на Opus 4.7) пок
OD стоит на плечах четырёх open-source проектов:
- [**`alchaincyf/huashu-design`**](https://github.com/alchaincyf/huashu-design) — философский компас дизайна. Junior-Designer workflow, 5-step protocol для brand assets, anti-AI-slop checklist, 5-dimensional self-critique и идея «5 schools × 20 design philosophies» для выбора направления — всё это distilled в [`apps/web/src/prompts/discovery.ts`](apps/web/src/prompts/discovery.ts).
- [**`alchaincyf/huashu-design`**](https://github.com/alchaincyf/huashu-design) — философский компас дизайна. Junior-Designer workflow, 5-step protocol для brand assets, anti-AI-slop checklist, 5-dimensional self-critique и идея «5 schools × 20 design philosophies» для выбора направления — всё это distilled в [`packages/contracts/src/prompts/discovery.ts`](packages/contracts/src/prompts/discovery.ts).
- [**`op7418/guizang-ppt-skill`**](https://github.com/op7418/guizang-ppt-skill) — режим deck. Встроен без изменений в [`skills/guizang-ppt/`](skills/guizang-ppt/) с сохранением исходной LICENSE; журнальные раскладки, WebGL hero и P0/P1/P2 checklists.
- [**`OpenCoworkAI/open-codesign`**](https://github.com/OpenCoworkAI/open-codesign) — UX-северная звезда и наш ближайший peer. Мы заимствуем streaming-artifact loop, шаблон sandboxed iframe preview (vendored React 18 + Babel), live agent panel (todos + tool calls + interruptible generation) и набор из пяти форматов экспорта (HTML / PDF / PPTX / ZIP / Markdown). Осознанное расхождение — в форм-факторе: они делают desktop Electron app с bundled [`pi-ai`][piai], мы — web app + local daemon, делегирующий работу вашему существующему CLI.
- [**`multica-ai/multica`**](https://github.com/multica-ai/multica) — архитектура демона и runtime. PATH-scan detection агентов, local daemon как единственный привилегированный процесс и мировоззрение agent-as-teammate.
@ -57,7 +57,7 @@ OD стоит на плечах четырёх open-source проектов:
| **Design systems built-in** | **129** — 2 вручную написанных стартера + 70 продуктовых систем (Linear, Stripe, Vercel, Airbnb, Tesla, Notion, Anthropic, Apple, Cursor, Supabase, Figma, Xiaohongshu, …) из [`awesome-design-md`][acd2], плюс 57 design skills из [`awesome-design-skills`][ads], добавленных напрямую в `design-systems/` |
| **Skills built-in** | **31** — 27 в режиме `prototype` (web-prototype, saas-landing, dashboard, mobile-app, gamified-app, social-carousel, magazine-poster, dating-web, sprite-animation, motion-frames, critique, tweaks, wireframe-sketch, pm-spec, eng-runbook, finance-report, hr-onboarding, invoice, kanban-board, team-okrs, …) + 4 в режиме `deck` (`guizang-ppt` · `simple-deck` · `replit-deck` · `weekly-update`). В picker группируются по `scenario`: design / marketing / operation / engineering / product / finance / hr / sale / personal. |
| **Media generation** | Режимы image · video · audio идут рядом с дизайн-циклом. **gpt-image-2** (Azure / OpenAI) — для постеров, аватаров, инфографики и иллюстрированных карт; **Seedance 2.0** (ByteDance) — для кинематографичных 15s text-to-video и image-to-video; **HyperFrames** ([heygen-com/hyperframes](https://github.com/heygen-com/hyperframes)) — для HTML→MP4 motion graphics (product reveals, kinetic typography, charts, social overlays, logo outros). Галерея из **93 готовых к воспроизведению промптов** — 43 для gpt-image-2, 39 для Seedance и 11 для HyperFrames — лежит в [`prompt-templates/`](prompt-templates/), с preview thumbnail и указанием источника. Тот же чатовый surface, что и для кода; на выходе в проектном workspace появляется реальный `.mp4` / `.png`. |
| **Visual directions** | 5 отобранных школ (Editorial Monocle · Modern Minimal · Warm Soft · Tech Utility · Brutalist Experimental) — каждая с детерминированной OKLch-палитрой и стеком шрифтов ([`apps/web/src/prompts/directions.ts`](apps/web/src/prompts/directions.ts)) |
| **Visual directions** | 5 отобранных школ (Editorial Monocle · Modern Minimal · Warm Soft · Tech Utility · Brutalist Experimental) — каждая с детерминированной OKLch-палитрой и стеком шрифтов ([`packages/contracts/src/prompts/directions.ts`](packages/contracts/src/prompts/directions.ts)) |
| **Device frames** | iPhone 15 Pro · Pixel · iPad Pro · MacBook · Browser Chrome — pixel-perfect, общие для навыков, хранятся в [`assets/frames/`](assets/frames/) |
| **Agent runtime** | Local daemon запускает CLI в папке проекта — агент получает реальные `Read`, `Write`, `Bash`, `WebFetch` поверх реальной on-disk среды, с Windows fallbackами для `ENAMETOOLONG` (stdin / prompt-file) на каждом адаптере |
| **Imports** | Перетащите ZIP-экспорт из [Claude Design][cd] в welcome dialog — `POST /api/import/claude-design` превратит его в реальный проект, чтобы ваш агент продолжил редактирование там, где остановился Anthropic |
@ -254,7 +254,7 @@ DISCOVERY directives (turn-1 form, turn-2 brand branch, TodoWrite, 5-dim critiq
+ (deck kind, no skill seed) DECK_FRAMEWORK_DIRECTIVE (nav / counter / scroll / print)
```
Каждый слой компонуем. Каждый слой — это файл, который можно редактировать. Откройте [`apps/web/src/prompts/system.ts`](apps/web/src/prompts/system.ts) и [`apps/web/src/prompts/discovery.ts`](apps/web/src/prompts/discovery.ts), чтобы увидеть реальный контракт.
Каждый слой компонуем. Каждый слой — это файл, который можно редактировать. Откройте [`packages/contracts/src/prompts/system.ts`](packages/contracts/src/prompts/system.ts) и [`packages/contracts/src/prompts/discovery.ts`](packages/contracts/src/prompts/discovery.ts), чтобы увидеть реальный контракт.
## Архитектура
@ -496,7 +496,7 @@ open-design/
| Brutalist | Сырой, с крупной типографикой, без теней, с жёсткими акцентами | Bloomberg Businessweek · Achtung |
| Soft warm | Просторный, низкоконтрастный, в персиково-нейтральной гамме | Notion marketing · Apple Health |
Полная спецификация → [`apps/web/src/prompts/directions.ts`](apps/web/src/prompts/directions.ts).
Полная спецификация → [`packages/contracts/src/prompts/directions.ts`](packages/contracts/src/prompts/directions.ts).
## Генерация медиа
@ -586,7 +586,7 @@ OD не заканчивается на коде. Тот же чатовый sur
## Механика против AI-slop
Вся эта механика — прямое переложение методологии [`huashu-design`](https://github.com/alchaincyf/huashu-design) в prompt stack OD с enforceом через side-file pre-flight. Текущие формулировки можно посмотреть в [`apps/web/src/prompts/discovery.ts`](apps/web/src/prompts/discovery.ts):
Вся эта механика — прямое переложение методологии [`huashu-design`](https://github.com/alchaincyf/huashu-design) в prompt stack OD с enforceом через side-file pre-flight. Текущие формулировки можно посмотреть в [`packages/contracts/src/prompts/discovery.ts`](packages/contracts/src/prompts/discovery.ts):
- **Сначала question form.** Ход 1 — только `<question-form>`, без размышлений, без tools, без narration. Пользователь выбирает дефолты со скоростью radio-click.
- **Извлечение brand spec.** Если пользователь прикладывает screenshot или URL, агент перед написанием CSS проходит пятишаговый протокол (locate · download · grep hex · codify `brand-spec.md` · vocalise). **Никогда не угадывает brand colors по памяти.**
@ -661,7 +661,7 @@ OD не заканчивается на коде. Тот же чатовый sur
| Project | Роль в проекте |
|---|---|
| [`Claude Design`][cd] | Закрытый продукт, для которого этот репозиторий служит open-source альтернативой. |
| [**`alchaincyf/huashu-design`**](https://github.com/alchaincyf/huashu-design) | Ядро дизайн-философии. Junior-Designer workflow, 5-step protocol для brand assets, anti-AI-slop checklist, пятимерная самокритика и библиотека «5 schools × 20 design philosophies» для direction pickerа — всё это distilled в [`apps/web/src/prompts/discovery.ts`](apps/web/src/prompts/discovery.ts) и [`apps/web/src/prompts/directions.ts`](apps/web/src/prompts/directions.ts). |
| [**`alchaincyf/huashu-design`**](https://github.com/alchaincyf/huashu-design) | Ядро дизайн-философии. Junior-Designer workflow, 5-step protocol для brand assets, anti-AI-slop checklist, пятимерная самокритика и библиотека «5 schools × 20 design philosophies» для direction pickerа — всё это distilled в [`packages/contracts/src/prompts/discovery.ts`](packages/contracts/src/prompts/discovery.ts) и [`packages/contracts/src/prompts/directions.ts`](packages/contracts/src/prompts/directions.ts). |
| [**`op7418/guizang-ppt-skill`**][guizang] | Skill для magazine-web-PPT, встроенный без изменений в [`skills/guizang-ppt/`](skills/guizang-ppt/) с сохранением оригинальной LICENSE. Используется по умолчанию в режиме deck. Культура P0/P1/P2 checklistов позаимствована для всех остальных skills. |
| [**`multica-ai/multica`**](https://github.com/multica-ai/multica) | Архитектура демона и адаптеров. PATH-scan detection агентов, local daemon как единственный привилегированный процесс, worldview agent-as-teammate. Мы переняли модель, а не vendored code. |
| [**`OpenCoworkAI/open-codesign`**][ocod] | Первая open-source альтернатива Claude Design и наш ближайший peer. Заимствованные UX-паттерны: streaming-artifact loop, sandboxed iframe preview (vendored React 18 + Babel), live agent panel (todos + tool calls + interruptible), список из пяти экспортных форматов (HTML/PDF/PPTX/ZIP/Markdown), local-first storage hub, `SKILL.md`-внедрение вкуса и первая версия preview-аннотаций для comment mode. Всё ещё в roadmap: полная надёжность surgical edits и tweaks panel, генерируемая ИИ. **Мы намеренно не вендорим [`pi-ai`][piai]** — open-codesign включает его как runtime агента, а мы делегируем исполнение тому CLI, который уже установлен у пользователя. |

View file

@ -43,7 +43,7 @@ Anthropic 的 [Claude Design][cd]2026-04-17 发布,基于 Opus 4.7)让大
OD 站在四个开源项目的肩膀上:
- [**`alchaincyf/huashu-design`**(花叔的画术)](https://github.com/alchaincyf/huashu-design) —— 设计哲学的指南针。Junior-Designer 工作流、5 步品牌资产协议、anti-AI-slop checklist、五维自评审、以及方向选择器背后的「5 流派 × 20 种设计哲学」思路 —— 全部蒸馏进 [`apps/web/src/prompts/discovery.ts`](apps/web/src/prompts/discovery.ts)。
- [**`alchaincyf/huashu-design`**(花叔的画术)](https://github.com/alchaincyf/huashu-design) —— 设计哲学的指南针。Junior-Designer 工作流、5 步品牌资产协议、anti-AI-slop checklist、五维自评审、以及方向选择器背后的「5 流派 × 20 种设计哲学」思路 —— 全部蒸馏进 [`packages/contracts/src/prompts/discovery.ts`](packages/contracts/src/prompts/discovery.ts)。
- [**`op7418/guizang-ppt-skill`**(歸藏的杂志风 PPT skill](https://github.com/op7418/guizang-ppt-skill) —— Deck 模式。原样捆绑在 [`skills/guizang-ppt/`](skills/guizang-ppt/) 下,原 LICENSE 保留杂志版式、WebGL hero、P0/P1/P2 checklist。
- [**`OpenCoworkAI/open-codesign`**](https://github.com/OpenCoworkAI/open-codesign) —— UX 北极星,也是我们最接近的同类。第一个开源的 Claude-Design 替代品。我们借鉴了它的流式 artifact 循环、沙盒 iframe 预览模式(自带 React 18 + Babel、实时 agent 面板todos + tool calls + 可中断生成、5 种导出格式列表HTML / PDF / PPTX / ZIP / Markdown。我们刻意在形态上分流 —— 它是桌面 Electron 应用,把 [`pi-ai`][piai] 打包进去做 agent我们是 Web 应用 + 本地 daemon把 agent 运行时**委托**给你已经装好的 CLI。
- [**`multica-ai/multica`**](https://github.com/multica-ai/multica) —— Daemon 与运行时架构。PATH 扫描式 agent 检测,本地 daemon 作为唯一的特权进程agent-as-teammate 的世界观。
@ -57,7 +57,7 @@ OD 站在四个开源项目的肩膀上:
| **内置 design system** | **72 套** —— 2 套手写起手 + 70 套从 [`awesome-design-md`][acd2] 导入的产品系统Linear、Stripe、Vercel、Airbnb、Tesla、Notion、Anthropic、Apple、Cursor、Supabase、Figma、小红书… |
| **内置 skill** | **31 个** —— 27 个 `prototype` 模式web-prototype、saas-landing、dashboard、mobile-app、gamified-app、social-carousel、magazine-poster、dating-web、sprite-animation、motion-frames、critique、tweaks、wireframe-sketch、pm-spec、eng-runbook、finance-report、hr-onboarding、invoice、kanban-board、team-okrs…+ 4 个 `deck` 模式(`guizang-ppt` · `simple-deck` · `replit-deck` · `weekly-update`。Picker 按 `scenario` 分组design / marketing / operation / engineering / product / finance / hr / sale / personal。 |
| **媒体生成** | 图像 · 视频 · 音频三类 surface 与设计循环并行可用。**gpt-image-2**Azure / OpenAI做海报、头像、信息图、城市插画地图 · **Seedance 2.0**(字节跳动)做 15 秒电影感 t2v + i2v · **HyperFrames**[heygen-com/hyperframes](https://github.com/heygen-com/hyperframes))做 HTML→MP4 动态图形产品揭示、动力学排版、数据图表、社媒卡片、Logo 收尾)。**93 条**可一键复刻的 prompt gallery —— 43 条 gpt-image-2 + 39 条 Seedance + 11 条 HyperFrames统一放在 [`prompt-templates/`](prompt-templates/) 下附预览图与来源署名。Chat 入口和写代码同一处;输出真实的 `.mp4` / `.png` 落到项目工作区里。 |
| **视觉方向** | 5 套精选流派Editorial Monocle · Modern Minimal · Warm Soft · Tech Utility · Brutalist Experimental每套自带 OKLch 色板 + 字体栈([`apps/web/src/prompts/directions.ts`](apps/web/src/prompts/directions.ts) |
| **视觉方向** | 5 套精选流派Editorial Monocle · Modern Minimal · Warm Soft · Tech Utility · Brutalist Experimental每套自带 OKLch 色板 + 字体栈([`packages/contracts/src/prompts/directions.ts`](packages/contracts/src/prompts/directions.ts) |
| **设备外壳** | iPhone 15 Pro · Pixel · iPad Pro · MacBook · Browser Chrome —— 像素级精确,跨 skill 共享,统一在 [`assets/frames/`](assets/frames/) |
| **Agent 运行时** | 本地 daemon 在你的项目目录里 spawn CLI —— agent 拥有真实的 `Read` / `Write` / `Bash` / `WebFetch`,作用在真实磁盘上;每个 adapter 都有 Windows `ENAMETOOLONG` 兜底stdin / 临时 prompt 文件) |
| **导入** | 把 [Claude Design][cd] 导出的 ZIP 直接拖到欢迎弹窗 —— `POST /api/import/claude-design` 解压成真实项目agent 接着 Anthropic 停下的地方继续编辑,不用再向模型重述上下文 |
@ -253,7 +253,7 @@ DISCOVERY 指令 turn-1 表单、turn-2 品牌分支、TodoWrite、
+ deck kind 且无 skill 种子时) DECK_FRAMEWORK_DIRECTIVE nav / counter / scroll / print
```
每一层都可组合。每一层都是一个你能改的文件。看 [`apps/web/src/prompts/system.ts`](apps/web/src/prompts/system.ts) 和 [`apps/web/src/prompts/discovery.ts`](apps/web/src/prompts/discovery.ts) 就知道真实契约长什么样。
每一层都可组合。每一层都是一个你能改的文件。看 [`packages/contracts/src/prompts/system.ts`](packages/contracts/src/prompts/system.ts) 和 [`packages/contracts/src/prompts/discovery.ts`](packages/contracts/src/prompts/discovery.ts) 就知道真实契约长什么样。
## 技术架构
@ -490,7 +490,7 @@ open-design/
| Brutalist | 粗粝、巨字、无阴影、刺眼强调 | Bloomberg Businessweek · Achtung |
| Soft warm | 大方、低对比、桃色中性 | Notion 营销页 · Apple Health |
完整 spec → [`apps/web/src/prompts/directions.ts`](apps/web/src/prompts/directions.ts)。
完整 spec → [`packages/contracts/src/prompts/directions.ts`](packages/contracts/src/prompts/directions.ts)。
## 媒体生成
@ -580,7 +580,7 @@ Chat / artifact 循环最显眼,但这套仓库里还有几个能力被埋得
## 反 AI Slop 机制
下面整套机制都是 [`huashu-design`](https://github.com/alchaincyf/huashu-design) 的 playbook被移植进 OD 的提示词栈,并通过 skill 副文件 pre-flight 让每个 skill 都能落地执行。看 [`apps/web/src/prompts/discovery.ts`](apps/web/src/prompts/discovery.ts) 是真实文案:
下面整套机制都是 [`huashu-design`](https://github.com/alchaincyf/huashu-design) 的 playbook被移植进 OD 的提示词栈,并通过 skill 副文件 pre-flight 让每个 skill 都能落地执行。看 [`packages/contracts/src/prompts/discovery.ts`](packages/contracts/src/prompts/discovery.ts) 是真实文案:
- **先表单。** Turn 1 必须是 `<question-form>`**不准** thinking、不准 tools、不准旁白。用户用 radio 速度选默认。
- **品牌资产协议。** 用户贴截图或 URL 时agent 走 5 步流程(定位 · 下载 · grep hex · 写 `brand-spec.md` · 复述)才能开始写 CSS。**绝不从记忆里猜品牌色**。
@ -655,7 +655,7 @@ Daemon 启动时从 `PATH` 自动检测,无需配置。流式分发逻辑在 [
| 项目 | 在这里的角色 |
|---|---|
| [`Claude Design`][cd] | 本仓库为之提供开源替代的闭源产品。 |
| [**`alchaincyf/huashu-design`**(花叔的画术)](https://github.com/alchaincyf/huashu-design) | 设计哲学的核心。Junior-Designer 工作流、5 步品牌资产协议、anti-AI-slop checklist、五维自评审、以及方向选择器背后的「5 流派 × 20 种设计哲学」库 —— 全部蒸馏进 [`apps/web/src/prompts/discovery.ts`](apps/web/src/prompts/discovery.ts) 与 [`apps/web/src/prompts/directions.ts`](apps/web/src/prompts/directions.ts)。 |
| [**`alchaincyf/huashu-design`**(花叔的画术)](https://github.com/alchaincyf/huashu-design) | 设计哲学的核心。Junior-Designer 工作流、5 步品牌资产协议、anti-AI-slop checklist、五维自评审、以及方向选择器背后的「5 流派 × 20 种设计哲学」库 —— 全部蒸馏进 [`packages/contracts/src/prompts/discovery.ts`](packages/contracts/src/prompts/discovery.ts) 与 [`packages/contracts/src/prompts/directions.ts`](packages/contracts/src/prompts/directions.ts)。 |
| [**`op7418/guizang-ppt-skill`**(歸藏)][guizang] | Magazine-web-PPT skill 原样捆绑在 [`skills/guizang-ppt/`](skills/guizang-ppt/) 下,原 LICENSE 保留。Deck 模式默认。P0/P1/P2 checklist 文化也被借给了所有其他 skill。 |
| [**`multica-ai/multica`**](https://github.com/multica-ai/multica) | Daemon + adapter 架构。PATH 扫描式 agent 检测、本地 daemon 作为唯一特权进程、agent-as-teammate 世界观。我们采纳模型,不 vendor 代码。 |
| [**`OpenCoworkAI/open-codesign`**][ocod] | 第一个开源的 Claude-Design 替代品,也是我们最接近的同类。已采纳的 UX 模式:流式 artifact 循环、沙盒 iframe 预览(自带 React 18 + Babel、实时 agent 面板todos + tool calls + 可中断、5 种导出格式列表HTML/PDF/PPTX/ZIP/Markdown、本地优先的 designs hub、`SKILL.md` 品味注入,以及评论模式预览标注的第一版。路线图上的 UX 模式:可靠的局部 patch 和 AI 自吐 tweaks 面板。**我们刻意不 vendor [`pi-ai`][piai]** —— open-codesign 把它打包成 agent 运行时;我们则委托给用户已经装好的 CLI。 |

View file

@ -42,7 +42,7 @@ Anthropic 的 [Claude Design][cd]2026-04-17 釋出,基於 Opus 4.7)讓大
OD 站在四個開源專案的肩膀上:
- [**`alchaincyf/huashu-design`**(花叔的畫術)](https://github.com/alchaincyf/huashu-design) —— 設計哲學的指南針。Junior-Designer 工作流、5 步品牌資產協議、anti-AI-slop checklist、五維自評審、以及方向選擇器背後的「5 流派 × 20 種設計哲學」思路 —— 全部蒸餾進 [`apps/web/src/prompts/discovery.ts`](apps/web/src/prompts/discovery.ts)。
- [**`alchaincyf/huashu-design`**(花叔的畫術)](https://github.com/alchaincyf/huashu-design) —— 設計哲學的指南針。Junior-Designer 工作流、5 步品牌資產協議、anti-AI-slop checklist、五維自評審、以及方向選擇器背後的「5 流派 × 20 種設計哲學」思路 —— 全部蒸餾進 [`packages/contracts/src/prompts/discovery.ts`](packages/contracts/src/prompts/discovery.ts)。
- [**`op7418/guizang-ppt-skill`**(歸藏的雜誌風 PPT skill](https://github.com/op7418/guizang-ppt-skill) —— Deck 模式。原樣納入在 [`skills/guizang-ppt/`](skills/guizang-ppt/) 下,原 LICENSE 保留雜誌版式、WebGL hero、P0/P1/P2 checklist。
- [**`OpenCoworkAI/open-codesign`**](https://github.com/OpenCoworkAI/open-codesign) —— UX 北極星,也是我們最接近的同類。第一個開源的 Claude-Design 替代品。我們借鑑了它的流式 artifact 迴圈、沙盒 iframe 預覽模式(自帶 React 18 + Babel、即時 agent 面板todos + tool calls + 可中斷生成、5 種匯出格式列表HTML / PDF / PPTX / ZIP / Markdown。我們刻意在形態上做出差異化 —— 它是桌面 Electron 應用,把 [`pi-ai`][piai] 打包進去做 agent我們是 Web 應用 + 本地 daemon把 agent 執行時**委託**給你已經裝好的 CLI。
- [**`multica-ai/multica`**](https://github.com/multica-ai/multica) —— Daemon 與執行時架構。PATH 掃描式 agent 檢測,本地 daemon 作為唯一的特權程序agent-as-teammate 的世界觀。
@ -55,7 +55,7 @@ OD 站在四個開源專案的肩膀上:
| **BYOK 備援** | OpenAI 相容代理 `/api/proxy/stream` —— 填 `baseUrl` + `apiKey` + `model`,任意 vendorAnthropic-via-OpenAI、DeepSeek、Groq、MiMo、OpenRouter、自託管 vLLM或任何 OpenAI 相容的 provider都能直接當引擎用。daemon 邊界拒絕 loopback / link-local / RFC1918 防 SSRF。 |
| **內建 design system** | **72 套** —— 2 套手寫起手 + 70 套從 [`awesome-design-md`][acd2] 匯入的產品系統Linear、Stripe、Vercel、Airbnb、Tesla、Notion、Anthropic、Apple、Cursor、Supabase、Figma、小紅書… |
| **內建 skill** | **31 個** —— 27 個 `prototype` 模式web-prototype、saas-landing、dashboard、mobile-app、gamified-app、social-carousel、magazine-poster、dating-web、sprite-animation、motion-frames、critique、tweaks、wireframe-sketch、pm-spec、eng-runbook、finance-report、hr-onboarding、invoice、kanban-board、team-okrs…+ 4 個 `deck` 模式(`guizang-ppt` · `simple-deck` · `replit-deck` · `weekly-update`。Picker 按 `scenario` 分組design / marketing / operation / engineering / product / finance / hr / sale / personal。 |
| **視覺方向** | 5 套精選流派Editorial Monocle · Modern Minimal · Warm Soft · Tech Utility · Brutalist Experimental每套自帶 OKLch 色票 + 字型堆疊([`apps/web/src/prompts/directions.ts`](apps/web/src/prompts/directions.ts) |
| **視覺方向** | 5 套精選流派Editorial Monocle · Modern Minimal · Warm Soft · Tech Utility · Brutalist Experimental每套自帶 OKLch 色票 + 字型堆疊([`packages/contracts/src/prompts/directions.ts`](packages/contracts/src/prompts/directions.ts) |
| **裝置外殼** | iPhone 15 Pro · Pixel · iPad Pro · MacBook · Browser Chrome —— 畫素級精確,跨 skill 共享,統一在 [`assets/frames/`](assets/frames/) |
| **Agent 執行時** | 本地 daemon 在你的專案目錄裡 spawn CLI —— agent 擁有真實的 `Read` / `Write` / `Bash` / `WebFetch`,作用在真實磁碟上;每個 adapter 都有 Windows `ENAMETOOLONG` 備援stdin / 臨時 prompt 檔案) |
| **匯入** | 把 [Claude Design][cd] 匯出的 ZIP 直接拖到歡迎彈窗 —— `POST /api/import/claude-design` 解壓成真實專案agent 接著 Anthropic 停下的地方繼續編輯,不用再向模型重述上下文 |
@ -251,7 +251,7 @@ DISCOVERY 指令 turn-1 表單、turn-2 品牌分支、TodoWrite、
+ deck kind 且無 skill 種子時) DECK_FRAMEWORK_DIRECTIVE nav / counter / scroll / print
```
每一層都可組合。每一層都是一個你能改的檔案。看 [`apps/web/src/prompts/system.ts`](apps/web/src/prompts/system.ts) 和 [`apps/web/src/prompts/discovery.ts`](apps/web/src/prompts/discovery.ts) 就知道真實契約長什麼樣。
每一層都可組合。每一層都是一個你能改的檔案。看 [`packages/contracts/src/prompts/system.ts`](packages/contracts/src/prompts/system.ts) 和 [`packages/contracts/src/prompts/discovery.ts`](packages/contracts/src/prompts/discovery.ts) 就知道真實契約長什麼樣。
## 技術架構
@ -557,7 +557,7 @@ open-design/
| Brutalist | 粗糲、巨字、無陰影、刺眼強調 | Bloomberg Businessweek · Achtung |
| Soft warm | 大方、低對比、桃色中性 | Notion 營銷頁 · Apple Health |
完整 spec → [`apps/web/src/prompts/directions.ts`](apps/web/src/prompts/directions.ts)。
完整 spec → [`packages/contracts/src/prompts/directions.ts`](packages/contracts/src/prompts/directions.ts)。
## 媒體生成
@ -647,7 +647,7 @@ Chat / artifact 迴圈最顯眼,但這套倉庫裡還有幾個能力被埋得
## 反 AI Slop 機制
下面整套機制都是 [`huashu-design`](https://github.com/alchaincyf/huashu-design) 的 playbook被移植進 OD 的提示詞堆疊,並透過 skill 副檔案 pre-flight 讓每個 skill 都能實作執行。看 [`apps/web/src/prompts/discovery.ts`](apps/web/src/prompts/discovery.ts) 是真實文案:
下面整套機制都是 [`huashu-design`](https://github.com/alchaincyf/huashu-design) 的 playbook被移植進 OD 的提示詞堆疊,並透過 skill 副檔案 pre-flight 讓每個 skill 都能實作執行。看 [`packages/contracts/src/prompts/discovery.ts`](packages/contracts/src/prompts/discovery.ts) 是真實文案:
- **先表單。** Turn 1 必須是 `<question-form>`**不準** thinking、不準 tools、不準旁白。使用者用 radio 速度選預設。
- **品牌資產協議。** 使用者貼截圖或 URL 時agent 走 5 步流程(定位 · 下載 · grep hex · 寫 `brand-spec.md` · 複述)才能開始寫 CSS。**絕不從記憶裡猜品牌色**。
@ -722,7 +722,7 @@ Daemon 啟動時從 `PATH` 自動檢測,無需配置。流式分發邏輯在 [
| 專案 | 在這裡的角色 |
|---|---|
| [`Claude Design`][cd] | 本倉庫為之提供開源替代的閉源產品。 |
| [**`alchaincyf/huashu-design`**(花叔的畫術)](https://github.com/alchaincyf/huashu-design) | 設計哲學的核心。Junior-Designer 工作流、5 步品牌資產協議、anti-AI-slop checklist、五維自評審、以及方向選擇器背後的「5 流派 × 20 種設計哲學」庫 —— 全部蒸餾進 [`apps/web/src/prompts/discovery.ts`](apps/web/src/prompts/discovery.ts) 與 [`apps/web/src/prompts/directions.ts`](apps/web/src/prompts/directions.ts)。 |
| [**`alchaincyf/huashu-design`**(花叔的畫術)](https://github.com/alchaincyf/huashu-design) | 設計哲學的核心。Junior-Designer 工作流、5 步品牌資產協議、anti-AI-slop checklist、五維自評審、以及方向選擇器背後的「5 流派 × 20 種設計哲學」庫 —— 全部蒸餾進 [`packages/contracts/src/prompts/discovery.ts`](packages/contracts/src/prompts/discovery.ts) 與 [`packages/contracts/src/prompts/directions.ts`](packages/contracts/src/prompts/directions.ts)。 |
| [**`op7418/guizang-ppt-skill`**(歸藏)][guizang] | Magazine-web-PPT skill 原樣納入在 [`skills/guizang-ppt/`](skills/guizang-ppt/) 下,原 LICENSE 保留。Deck 模式預設。P0/P1/P2 checklist 文化也被借給了所有其他 skill。 |
| [**`multica-ai/multica`**](https://github.com/multica-ai/multica) | Daemon + adapter 架構。PATH 掃描式 agent 檢測、本地 daemon 作為唯一特權程序、agent-as-teammate 世界觀。我們採納模型,不 vendor 程式碼。 |
| [**`OpenCoworkAI/open-codesign`**][ocod] | 第一個開源的 Claude-Design 替代品,也是我們最接近的同類。已採納的 UX 模式:流式 artifact 迴圈、沙盒 iframe 預覽(自帶 React 18 + Babel、即時 agent 面板todos + tool calls + 可中斷、5 種匯出格式列表HTML/PDF/PPTX/ZIP/Markdown、本地優先的 designs hub、`SKILL.md` 品味注入。路線圖上的 UX 模式評論模式手術刀編輯、AI 自吐 tweaks 面板。**我們刻意不 vendor [`pi-ai`][piai]** —— open-codesign 把它打包成 agent 執行時;我們則委託給使用者已經裝好的 CLI。 |

View file

@ -29,7 +29,7 @@
"dev": "pnpm run build && node dist/cli.js --no-open",
"start": "pnpm run build && node dist/cli.js",
"test": "vitest run -c vitest.config.ts",
"typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.tests.json --noEmit"
"typecheck": "pnpm --filter @open-design/contracts build && tsc -p tsconfig.json --noEmit && tsc -p tsconfig.tests.json --noEmit"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.0.0",
@ -42,13 +42,13 @@
"chokidar": "^5.0.0",
"express": "^4.19.2",
"jszip": "^3.10.1",
"multer": "^1.4.5-lts.1",
"multer": "^2.1.1",
"undici": "^7.16.0"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.13",
"@types/express": "^4.17.21",
"@types/multer": "^1.4.12",
"@types/multer": "^2.1.0",
"@types/node": "^20.17.10",
"typescript": "^5.6.3",
"vitest": "^2.1.8"

View file

@ -1,12 +1,66 @@
// @ts-nocheck
import { spawn } from 'node:child_process';
import { spawn, type ChildProcess } from 'node:child_process';
import type { Writable } from 'node:stream';
import path from 'node:path';
const ACP_PROTOCOL_VERSION = 1;
const DEFAULT_TIMEOUT_MS = 15_000;
const DEFAULT_STAGE_TIMEOUT_MS = 180_000;
export function buildAcpSessionNewParams(cwd, { mcpServers } = {}) {
type JsonRpcId = string | number;
type JsonObject = Record<string, unknown>;
type RpcWritable = Pick<Writable, 'write' | 'end'>;
type AcpChildProcess = ChildProcess;
type TimerHandle = ReturnType<typeof setTimeout>;
export interface AcpMcpServerInput {
type?: unknown;
name?: unknown;
command?: unknown;
args?: unknown;
env?: unknown;
}
interface AcpSessionOptions {
mcpServers?: AcpMcpServerInput[];
}
export interface ModelOption {
id: string;
label: string;
}
interface DetectAcpModelsOptions {
bin: string;
args: string[];
cwd?: string;
env?: NodeJS.ProcessEnv;
timeoutMs?: number;
clientName?: string;
clientVersion?: string;
defaultModelOption?: ModelOption;
}
interface AttachAcpSessionOptions {
child: AcpChildProcess;
prompt: string;
cwd?: string;
model?: string | null;
mcpServers?: AcpMcpServerInput[];
send: (event: string, payload: unknown) => void;
clientName?: string;
clientVersion?: string;
stageTimeoutMs?: number;
}
function errorMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
function asObject(value: unknown): JsonObject | null {
return value && typeof value === 'object' ? value as JsonObject : null;
}
export function buildAcpSessionNewParams(cwd: string, { mcpServers }: AcpSessionOptions = {}) {
const servers = Array.isArray(mcpServers) ? mcpServers : [];
return {
cwd: path.resolve(cwd),
@ -25,49 +79,60 @@ export function buildAcpSessionNewParams(cwd, { mcpServers } = {}) {
};
}
function sendRpc(writable, id, method, params) {
function sendRpc(writable: RpcWritable, id: JsonRpcId, method: string, params: unknown): void {
writable.write(
`${JSON.stringify({ jsonrpc: '2.0', id, method, params })}\n`,
);
}
function sendRpcResult(writable, id, result) {
function sendRpcResult(writable: RpcWritable, id: JsonRpcId, result: unknown): void {
writable.write(`${JSON.stringify({ jsonrpc: '2.0', id, result })}\n`);
}
function isJsonRpcId(value) {
function isJsonRpcId(value: unknown): value is JsonRpcId {
return typeof value === 'number' || typeof value === 'string';
}
function rpcErrorMessage(raw) {
if (!raw || typeof raw !== 'object' || !raw.error || typeof raw.error !== 'object') {
function rpcErrorMessage(raw: unknown): string {
const obj = asObject(raw);
const error = asObject(obj?.error);
if (!obj || !error) {
return '';
}
const message =
typeof raw.error.message === 'string'
? raw.error.message
: typeof raw.error.code === 'number'
? String(raw.error.code)
typeof error.message === 'string'
? error.message
: typeof error.code === 'number'
? String(error.code)
: 'json-rpc error';
return typeof raw.id === 'number'
? `json-rpc id ${raw.id}: ${message}`
return typeof obj.id === 'number'
? `json-rpc id ${obj.id}: ${message}`
: message;
}
function formatUsage(usage) {
if (!usage || typeof usage !== 'object') return null;
const out = {};
if (typeof usage.inputTokens === 'number') out.input_tokens = usage.inputTokens;
if (typeof usage.outputTokens === 'number') out.output_tokens = usage.outputTokens;
if (typeof usage.cachedReadTokens === 'number') {
out.cached_read_tokens = usage.cachedReadTokens;
interface FormattedUsage {
input_tokens?: number;
output_tokens?: number;
cached_read_tokens?: number;
thought_tokens?: number;
total_tokens?: number;
}
function formatUsage(usage: unknown): FormattedUsage | null {
const src = asObject(usage);
if (!src) return null;
const out: FormattedUsage = {};
if (typeof src.inputTokens === 'number') out.input_tokens = src.inputTokens;
if (typeof src.outputTokens === 'number') out.output_tokens = src.outputTokens;
if (typeof src.cachedReadTokens === 'number') {
out.cached_read_tokens = src.cachedReadTokens;
}
if (typeof usage.thoughtTokens === 'number') out.thought_tokens = usage.thoughtTokens;
if (typeof usage.totalTokens === 'number') out.total_tokens = usage.totalTokens;
if (typeof src.thoughtTokens === 'number') out.thought_tokens = src.thoughtTokens;
if (typeof src.totalTokens === 'number') out.total_tokens = src.totalTokens;
return Object.keys(out).length > 0 ? out : null;
}
function choosePermissionOutcome(options) {
function choosePermissionOutcome(options: unknown): string | null {
const list = Array.isArray(options) ? options : [];
const approveForSession = list.find((option) => option?.optionId === 'approve_for_session');
if (approveForSession) return 'approve_for_session';
@ -78,10 +143,11 @@ function choosePermissionOutcome(options) {
return null;
}
function normalizeModels(models, defaultModelOption) {
const available = Array.isArray(models?.availableModels) ? models.availableModels : [];
function normalizeModels(models: unknown, defaultModelOption: ModelOption): ModelOption[] {
const modelsObj = asObject(models);
const available = Array.isArray(modelsObj?.availableModels) ? modelsObj.availableModels : [];
const currentModelId =
typeof models?.currentModelId === 'string' ? models.currentModelId : null;
typeof modelsObj?.currentModelId === 'string' ? modelsObj.currentModelId : null;
const seen = new Set([defaultModelOption.id]);
const out = [defaultModelOption];
for (const model of available) {
@ -96,10 +162,10 @@ function normalizeModels(models, defaultModelOption) {
return out;
}
export function createJsonLineStream(onMessage) {
export function createJsonLineStream(onMessage: (message: unknown, rawLine: string) => void) {
let buffer = '';
return {
feed(chunk) {
feed(chunk: string) {
buffer += chunk;
const lines = buffer.split('\n');
buffer = lines.pop() || '';
@ -135,8 +201,8 @@ export async function detectAcpModels({
clientName = 'open-design-detect',
clientVersion = 'runtime-adapter',
defaultModelOption = { id: 'default', label: 'Default (CLI config)' },
}) {
return await new Promise((resolve, reject) => {
}: DetectAcpModelsOptions): Promise<ModelOption[]> {
return await new Promise<ModelOption[]>((resolve, reject) => {
const child = spawn(bin, args, {
cwd,
stdio: ['pipe', 'pipe', 'pipe'],
@ -150,7 +216,8 @@ export async function detectAcpModels({
let expectedId = 1;
let nextId = 2;
const finish = (fn, value) => {
let timer: TimerHandle;
const finish = <T extends ModelOption[] | Error>(fn: (value: T) => void, value: T) => {
if (settled) return;
settled = true;
clearTimeout(timer);
@ -160,16 +227,16 @@ export async function detectAcpModels({
fn(value);
};
const fail = (message) => {
const fail = (message: string) => {
finish(reject, new Error(message));
if (!child.killed) child.kill('SIGTERM');
};
const writeRpc = (id, method, params) => {
const writeRpc = (id: JsonRpcId, method: string, params: unknown) => {
try {
sendRpc(child.stdin, id, method, params);
} catch (err) {
fail(`stdin write failed: ${err.message}`);
fail(`stdin write failed: ${errorMessage(err)}`);
}
};
@ -180,23 +247,26 @@ export async function detectAcpModels({
};
const parser = createJsonLineStream((raw) => {
const obj = asObject(raw);
const error = asObject(obj?.error);
const result = asObject(obj?.result);
const rpcErr = rpcErrorMessage(raw);
if (rpcErr) {
// JSON-RPC -32603 "Internal error" during model detection:
// If this is for the current expected-id (initialize/session/new),
// it's a real probe failure — reject immediately.
// Otherwise it's cleanup noise — suppress it.
if (raw.error?.code === -32603 && raw.id !== expectedId) return;
if (error?.code === -32603 && obj?.id !== expectedId) return;
fail(rpcErr);
return;
}
if (raw.id !== expectedId || !raw.result || typeof raw.result !== 'object') return;
if (obj?.id !== expectedId || !result) return;
if (expectedId === 1) {
sendSessionNew();
return;
}
if (expectedId === 2) {
const models = normalizeModels(raw.result.models, defaultModelOption);
const models = normalizeModels(result.models, defaultModelOption);
finish(resolve, models);
if (!child.killed) child.kill('SIGTERM');
}
@ -218,7 +288,7 @@ export async function detectAcpModels({
}
});
const timer = setTimeout(() => {
timer = setTimeout(() => {
fail(`ACP model detection timed out after ${timeoutMs}ms`);
}, timeoutMs);
@ -240,34 +310,40 @@ export function attachAcpSession({
clientName = 'open-design',
clientVersion = 'runtime-adapter',
stageTimeoutMs = DEFAULT_STAGE_TIMEOUT_MS,
}) {
}: AttachAcpSessionOptions) {
const runStartedAt = Date.now();
const effectiveCwd = path.resolve(cwd || process.cwd());
if (!child.stdin || !child.stdout) {
throw new Error('ACP child process must expose stdin and stdout streams');
}
const stdin = child.stdin;
const stdout = child.stdout;
let expectedId = 1;
let nextId = 2;
let promptRequestId = null;
let setModelRequestId = null;
let sessionId = null;
let activeModel = null;
let promptRequestId: JsonRpcId | null = null;
let setModelRequestId: JsonRpcId | null = null;
let sessionId: string | null = null;
let activeModel: string | null = null;
let emittedThinkingStart = false;
let emittedFirstTokenStatus = false;
let finished = false;
let fatal = false;
let stageTimer = null;
let aborted = false;
let stageTimer: TimerHandle | null = null;
const resetStageTimer = (label) => {
clearTimeout(stageTimer);
const resetStageTimer = (label: string) => {
if (stageTimer) clearTimeout(stageTimer);
stageTimer = setTimeout(() => {
fail(`ACP ${label} timed out after ${stageTimeoutMs}ms`);
}, stageTimeoutMs);
};
const clearStageTimer = () => {
clearTimeout(stageTimer);
if (stageTimer) clearTimeout(stageTimer);
stageTimer = null;
};
const fail = (message) => {
const fail = (message: string) => {
if (finished) return;
finished = true;
fatal = true;
@ -276,12 +352,12 @@ export function attachAcpSession({
if (!child.killed) child.kill('SIGTERM');
};
const writeRpc = (id, method, params, timeoutLabel) => {
const writeRpc = (id: JsonRpcId, method: string, params: unknown, timeoutLabel: string) => {
resetStageTimer(timeoutLabel);
try {
sendRpc(child.stdin, id, method, params);
sendRpc(stdin, id, method, params);
} catch (err) {
fail(`stdin write failed: ${err.message}`);
fail(`stdin write failed: ${errorMessage(err)}`);
}
};
@ -300,25 +376,32 @@ export function attachAcpSession({
nextId += 1;
};
const replyPermission = (raw) => {
const optionId = choosePermissionOutcome(raw.params?.options);
const replyPermission = (raw: JsonObject) => {
const params = asObject(raw.params);
const optionId = choosePermissionOutcome(params?.options);
if (!optionId || !isJsonRpcId(raw.id)) {
fail(`unhandled ACP permission request: ${JSON.stringify(raw)}`);
return;
}
resetStageTimer('session/request_permission');
try {
sendRpcResult(child.stdin, raw.id, {
sendRpcResult(stdin, raw.id, {
outcome: { outcome: 'selected', optionId },
});
} catch (err) {
fail(`stdin write failed: ${err.message}`);
fail(`stdin write failed: ${errorMessage(err)}`);
}
};
const parser = createJsonLineStream((raw, rawLine) => {
if (aborted) return;
resetStageTimer('response');
const rpcErr = rpcErrorMessage(raw);
const obj = asObject(raw);
if (!obj) return;
const error = asObject(obj.error);
const params = asObject(obj.params);
const result = asObject(obj.result);
const rpcErr = rpcErrorMessage(obj);
if (rpcErr) {
// After response completion, any late-arriving errors from the agent
// (pipe-broken, cleanup race conditions, etc.) are safe to ignore.
@ -332,33 +415,33 @@ export function attachAcpSession({
// suppress when they match setModelRequestId so the recovery block handles
// them. Any other -32602 (unexpected-id or non-set_model expected-id) is
// a genuine protocol error — call fail().
if (raw.error?.code === -32603 && raw.id !== expectedId) {
if (error?.code === -32603 && obj.id !== expectedId) {
return;
}
if (raw.error?.code === -32602 && raw.id !== setModelRequestId) {
if (error?.code === -32602 && obj.id !== setModelRequestId) {
fail(rpcErr);
return;
}
if (raw.error?.code === -32603 && raw.id === expectedId) {
if (raw.id === setModelRequestId) {
if (error?.code === -32603 && obj.id === expectedId) {
if (obj.id === setModelRequestId) {
// Fall through — the recovery block will handle this
} else {
fail(rpcErr);
return;
}
}
if (raw.error?.code === -32602 && raw.id === setModelRequestId) {
if (error?.code === -32602 && obj.id === setModelRequestId) {
// Fall through — the recovery block will handle this
}
}
if (raw.method === 'session/request_permission') {
replyPermission(raw);
if (obj.method === 'session/request_permission') {
replyPermission(obj);
return;
}
if (raw.method === 'session/update' && raw.params?.update) {
const update = raw.params.update;
const update = asObject(params?.update);
if (obj.method === 'session/update' && update) {
if (update.sessionUpdate === 'agent_thought_chunk') {
const text = update.content?.text;
const text = asObject(update.content)?.text;
if (typeof text === 'string' && text.length > 0) {
if (!emittedThinkingStart) {
emittedThinkingStart = true;
@ -369,7 +452,7 @@ export function attachAcpSession({
return;
}
if (update.sessionUpdate === 'agent_message_chunk') {
const text = update.content?.text;
const text = asObject(update.content)?.text;
if (typeof text === 'string' && text.length > 0) {
if (!emittedFirstTokenStatus) {
emittedFirstTokenStatus = true;
@ -392,8 +475,8 @@ export function attachAcpSession({
// This is scoped to the exact set_model request id to avoid
// triggering on prompt or other request failures.
if (
(raw.error?.code === -32603 || raw.error?.code === -32602) &&
raw.id === setModelRequestId &&
(error?.code === -32603 || error?.code === -32602) &&
obj.id === setModelRequestId &&
promptRequestId === null
) {
setModelRequestId = null;
@ -402,7 +485,7 @@ export function attachAcpSession({
sendPrompt();
return;
}
if (raw.id !== expectedId || !raw.result || typeof raw.result !== 'object') {
if (obj.id !== expectedId || !result) {
return;
}
if (expectedId === 1) {
@ -410,17 +493,21 @@ export function attachAcpSession({
writeRpc(
nextId,
'session/new',
buildAcpSessionNewParams(effectiveCwd, { mcpServers }),
buildAcpSessionNewParams(
effectiveCwd,
mcpServers ? { mcpServers } : {},
),
'session/new',
);
nextId += 1;
return;
}
if (expectedId === 2) {
sessionId = typeof raw.result.sessionId === 'string' ? raw.result.sessionId : null;
sessionId = typeof result.sessionId === 'string' ? result.sessionId : null;
const models = asObject(result.models);
activeModel =
typeof raw.result.models?.currentModelId === 'string'
? raw.result.models.currentModelId
typeof models?.currentModelId === 'string'
? models.currentModelId
: null;
if (sessionId && activeModel) {
send('agent', { type: 'status', label: 'model', model: activeModel });
@ -447,8 +534,8 @@ export function attachAcpSession({
sendPrompt();
return;
}
if (promptRequestId !== null && raw.id === promptRequestId) {
const usage = formatUsage(raw.result.usage);
if (promptRequestId !== null && obj.id === promptRequestId) {
const usage = formatUsage(result.usage);
if (usage) {
send('agent', {
type: 'usage',
@ -458,23 +545,23 @@ export function attachAcpSession({
}
finished = true;
clearStageTimer();
child.stdin.end();
stdin.end();
return;
}
if (sessionId && model && model !== 'default' && raw.id === expectedId) {
if (sessionId && model && model !== 'default' && obj.id === expectedId) {
activeModel = model;
send('agent', { type: 'status', label: 'model', model: activeModel });
sendPrompt();
}
});
child.stdout.on('data', (chunk) => parser.feed(chunk));
stdout.on('data', (chunk: string) => parser.feed(chunk));
child.on('close', () => {
clearStageTimer();
parser.flush();
});
child.on('error', (err) => fail(err.message));
child.stdin.on('error', (err) => fail(`stdin error: ${err.message}`));
child.on('error', (err: Error) => fail(err.message));
stdin.on('error', (err: Error) => fail(`stdin error: ${err.message}`));
writeRpc(1, 'initialize', {
protocolVersion: ACP_PROTOCOL_VERSION,
@ -486,5 +573,18 @@ export function attachAcpSession({
hasFatalError() {
return fatal;
},
abort() {
if (aborted || finished) return;
aborted = true;
finished = true;
clearStageTimer();
if (!sessionId || !child.stdin || child.stdin.destroyed || child.stdin.writableEnded) return;
try {
sendRpc(child.stdin, nextId, 'session/cancel', { sessionId });
nextId += 1;
} catch {
// The caller owns process-signal fallback if the ACP transport is gone.
}
},
};
}

View file

@ -1,4 +1,3 @@
// @ts-nocheck
import path from 'node:path';
const MANIFEST_VERSION = 1;
@ -10,7 +9,13 @@ const MAX_SUPPORTING_FILE_LENGTH = 260;
const MAX_SUPPORTING_FILES = 128;
const MAX_METADATA_BYTES = 16 * 1024;
const ALLOWED_KINDS = new Set([
type JsonRecord = Record<string, unknown>;
type ValidationResult =
| { ok: true; value: JsonRecord | null }
| { ok: false; error: string };
const ALLOWED_KINDS = new Set<string>([
'html',
'deck',
'react-component',
@ -22,7 +27,7 @@ const ALLOWED_KINDS = new Set([
'design-system',
]);
const ALLOWED_RENDERERS = new Set([
const ALLOWED_RENDERERS = new Set<string>([
'html',
'deck-html',
'react-component',
@ -34,23 +39,28 @@ const ALLOWED_RENDERERS = new Set([
'design-system',
]);
const ALLOWED_EXPORTS = new Set(['html', 'pdf', 'zip', 'pptx', 'jsx', 'md', 'svg', 'txt']);
const ALLOWED_STATUS = new Set(['streaming', 'complete', 'error']);
const ALLOWED_EXPORTS = new Set<string>(['html', 'pdf', 'zip', 'pptx', 'jsx', 'md', 'svg', 'txt']);
const ALLOWED_STATUS = new Set<string>(['streaming', 'complete', 'error']);
function isPlainObject(value) {
function isPlainObject(value: unknown): value is JsonRecord {
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
const proto = Object.getPrototypeOf(value);
return proto === Object.prototype || proto === null;
}
function validateBoundedString(value, field, maxLen, { allowEmpty = false } = {}) {
function validateBoundedString(
value: unknown,
field: string,
maxLen: number,
{ allowEmpty = false }: { allowEmpty?: boolean } = {},
): string | null {
if (typeof value !== 'string') return `${field} must be a string`;
if (!allowEmpty && value.length === 0) return `${field} is required`;
if (value.length > maxLen) return `${field} exceeds max length (${maxLen})`;
return null;
}
function validateSupportingPath(value) {
function validateSupportingPath(value: unknown): string | null {
if (typeof value !== 'string') return 'supportingFiles entries must be strings';
if (value.length === 0) return 'supportingFiles entries cannot be empty';
if (value.length > MAX_SUPPORTING_FILE_LENGTH) {
@ -69,7 +79,7 @@ function validateSupportingPath(value) {
return null;
}
export function validateArtifactManifestInput(manifest, entry) {
export function validateArtifactManifestInput(manifest: unknown, entry: unknown): ValidationResult {
if (manifest == null) return { ok: true, value: null };
if (!isPlainObject(manifest)) {
return { ok: false, error: 'artifactManifest must be an object' };
@ -77,12 +87,18 @@ export function validateArtifactManifestInput(manifest, entry) {
const kindErr = validateBoundedString(manifest.kind, 'artifactManifest.kind', 64);
if (kindErr) return { ok: false, error: kindErr };
if (typeof manifest.kind !== 'string') {
return { ok: false, error: 'artifactManifest.kind must be a string' };
}
if (!ALLOWED_KINDS.has(manifest.kind)) {
return { ok: false, error: 'artifactManifest.kind is not allowed' };
}
const rendererErr = validateBoundedString(manifest.renderer, 'artifactManifest.renderer', 64);
if (rendererErr) return { ok: false, error: rendererErr };
if (typeof manifest.renderer !== 'string') {
return { ok: false, error: 'artifactManifest.renderer must be a string' };
}
if (!ALLOWED_RENDERERS.has(manifest.renderer)) {
return { ok: false, error: 'artifactManifest.renderer is not allowed' };
}
@ -178,7 +194,7 @@ export function validateArtifactManifestInput(manifest, entry) {
return { ok: true, value: sanitizeManifest(manifest, safeEntry) };
}
export function sanitizeManifest(manifest, entry) {
export function sanitizeManifest(manifest: JsonRecord, entry: string): JsonRecord {
const now = new Date().toISOString();
return {
version: MANIFEST_VERSION,
@ -186,10 +202,10 @@ export function sanitizeManifest(manifest, entry) {
title: manifest.title || entry,
entry,
renderer: manifest.renderer,
status: ALLOWED_STATUS.has(manifest.status) ? manifest.status : 'complete',
status: typeof manifest.status === 'string' && ALLOWED_STATUS.has(manifest.status) ? manifest.status : 'complete',
exports: manifest.exports,
supportingFiles: Array.isArray(manifest.supportingFiles)
? manifest.supportingFiles.map((x) => x.replace(/\\/g, '/'))
? manifest.supportingFiles.map((x) => String(x).replace(/\\/g, '/'))
: undefined,
createdAt: typeof manifest.createdAt === 'string' ? manifest.createdAt : now,
updatedAt: now,
@ -199,7 +215,7 @@ export function sanitizeManifest(manifest, entry) {
};
}
export function parsePersistedManifest(raw, fallbackEntry) {
export function parsePersistedManifest(raw: string, fallbackEntry: string): JsonRecord | null {
try {
const parsed = JSON.parse(raw);
if (!parsed || parsed.version !== MANIFEST_VERSION) return null;
@ -211,7 +227,7 @@ export function parsePersistedManifest(raw, fallbackEntry) {
}
}
export function inferLegacyManifest(entry) {
export function inferLegacyManifest(entry: string): JsonRecord | null {
const lower = entry.toLowerCase();
const ext = path.extname(lower);
// NOTE: This duplicate heuristic must stay in sync with

View file

@ -1,4 +1,3 @@
// @ts-nocheck
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { inflateRawSync } from 'node:zlib';
@ -12,10 +11,21 @@ const MAX_FILES = 5000;
const MAX_TOTAL_BYTES = 100 * 1024 * 1024;
const MAX_FILE_BYTES = 25 * 1024 * 1024;
export async function importClaudeDesignZip(zipPath, projectDir) {
type ZipEntry = {
name: string;
method: number;
compressedSize: number;
uncompressedSize: number;
localOffset: number;
isDirectory: boolean;
};
type ImportedFile = { path: string; body: Buffer };
export async function importClaudeDesignZip(zipPath: string, projectDir: string) {
const zip = await readFile(zipPath);
const entries = readCentralDirectory(zip);
const files = [];
const files: ImportedFile[] = [];
let totalBytes = 0;
for (const entry of entries) {
@ -47,8 +57,8 @@ export async function importClaudeDesignZip(zipPath, projectDir) {
const entryFile = chooseEntryFile(files.map((f) => f.path));
if (!entryFile) throw new Error('zip does not contain an HTML file');
const dirCreates = new Map();
const ensureDir = (dir) => {
const dirCreates = new Map<string, Promise<string | undefined>>();
const ensureDir = (dir: string) => {
let pending = dirCreates.get(dir);
if (!pending) {
pending = mkdir(dir, { recursive: true });
@ -70,7 +80,7 @@ export async function importClaudeDesignZip(zipPath, projectDir) {
};
}
function readCentralDirectory(zip) {
function readCentralDirectory(zip: Buffer): ZipEntry[] {
const eocdOffset = findEndOfCentralDirectory(zip);
const entryCount = zip.readUInt16LE(eocdOffset + 10);
const centralSize = zip.readUInt32LE(eocdOffset + 12);
@ -79,7 +89,7 @@ function readCentralDirectory(zip) {
throw new Error('invalid zip central directory');
}
const entries = [];
const entries: ZipEntry[] = [];
let offset = centralOffset;
for (let i = 0; i < entryCount; i += 1) {
if (zip.readUInt32LE(offset) !== CENTRAL_SIG) {
@ -111,7 +121,7 @@ function readCentralDirectory(zip) {
return entries;
}
function findEndOfCentralDirectory(zip) {
function findEndOfCentralDirectory(zip: Buffer): number {
const min = Math.max(0, zip.length - 0xffff - 22);
for (let i = zip.length - 22; i >= min; i -= 1) {
if (zip.readUInt32LE(i) === EOCD_SIG) return i;
@ -119,7 +129,7 @@ function findEndOfCentralDirectory(zip) {
throw new Error('invalid zip: missing central directory');
}
function readEntryBody(zip, entry) {
function readEntryBody(zip: Buffer, entry: ZipEntry): Buffer {
const offset = entry.localOffset;
if (zip.readUInt32LE(offset) !== LOCAL_SIG) {
throw new Error(`invalid zip local header: ${entry.name}`);
@ -143,7 +153,7 @@ function readEntryBody(zip, entry) {
return inflateRawSync(compressed, { maxOutputLength: cap });
}
function sanitizeZipPath(name) {
function sanitizeZipPath(name: string): string {
if (name.includes('\0')) throw new Error('invalid zip file name');
if (/^[A-Za-z]:/.test(name) || name.startsWith('/')) {
throw new Error('absolute zip paths are not allowed');
@ -151,7 +161,7 @@ function sanitizeZipPath(name) {
return validateProjectPath(name);
}
function chooseEntryFile(paths) {
function chooseEntryFile(paths: string[]): string | null {
const html = paths.filter((p) => /\.html?$/i.test(p));
if (html.length === 0) return null;
const lower = new Map(html.map((p) => [p.toLowerCase(), p]));
@ -163,7 +173,7 @@ function chooseEntryFile(paths) {
);
}
function safeJoin(root, relPath) {
function safeJoin(root: string, relPath: string): string {
const target = path.resolve(root, relPath);
if (!target.startsWith(root + path.sep) && target !== root) {
throw new Error('path escapes project dir');

View file

@ -1,4 +1,3 @@
// @ts-nocheck
/**
* Parses Claude Code's `--output-format stream-json --verbose` JSONL stream
* (with or without `--include-partial-messages`) into a small set of
@ -20,27 +19,35 @@
* `tool_use` event when that block stops.
*/
export function createClaudeStreamHandler(onEvent) {
type StreamEvent = Record<string, unknown>;
type EventSink = (event: StreamEvent) => void;
type BlockState = { type?: unknown; name?: unknown; id?: unknown; input: string };
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}
export function createClaudeStreamHandler(onEvent: EventSink) {
let buffer = '';
// Per-content-block scratch, keyed by `${messageId}:${blockIndex}`.
const blocks = new Map();
const blocks = new Map<string, BlockState>();
// Most recent assistant message id so content_block_* events without an id
// can be attributed correctly.
let currentMessageId = null;
let currentMessageId: string | null = null;
// Message ids that already streamed text via `stream_event` deltas.
// When `--include-partial-messages` is OFF (older Claude Code, e.g. 1.0.84
// pre-flag), no deltas arrive — only the final `assistant` wrapper carries
// text. The fallback below emits that text once, but we must skip it for
// newer builds that already streamed deltas, otherwise the message would
// duplicate.
const textStreamed = new Set();
const textStreamed = new Set<string>();
function blockKey(index) {
function blockKey(index: unknown): string {
return `${currentMessageId ?? 'anon'}:${index}`;
}
function feed(chunk) {
function feed(chunk: string) {
buffer += chunk;
let nl;
while ((nl = buffer.indexOf('\n')) !== -1) {
@ -69,8 +76,8 @@ export function createClaudeStreamHandler(onEvent) {
}
}
function handleObject(obj) {
if (!obj || typeof obj !== 'object') return;
function handleObject(obj: unknown) {
if (!isRecord(obj)) return;
if (obj.type === 'system' && obj.subtype === 'init') {
onEvent({
@ -87,7 +94,7 @@ export function createClaudeStreamHandler(onEvent) {
return;
}
if (obj.type === 'stream_event' && obj.event) {
if (obj.type === 'stream_event' && isRecord(obj.event)) {
handleStreamEvent(obj.event);
return;
}
@ -98,11 +105,12 @@ export function createClaudeStreamHandler(onEvent) {
// the text as a single delta — but only if no streaming deltas already
// covered it (older Claude Code without --include-partial-messages
// delivers text only here; newer builds stream it and would duplicate).
if (obj.type === 'assistant' && obj.message?.content) {
currentMessageId = obj.message.id ?? currentMessageId;
const msgId = obj.message.id ?? null;
if (obj.type === 'assistant' && isRecord(obj.message) && Array.isArray(obj.message.content)) {
currentMessageId = typeof obj.message.id === 'string' ? obj.message.id : currentMessageId;
const msgId = typeof obj.message.id === 'string' ? obj.message.id : null;
const alreadyStreamed = msgId ? textStreamed.has(msgId) : false;
for (const block of obj.message.content) {
if (!isRecord(block)) continue;
if (block.type === 'tool_use') {
onEvent({
type: 'tool_use',
@ -131,8 +139,9 @@ export function createClaudeStreamHandler(onEvent) {
// `user` messages in a stream-json transcript are usually tool_result
// wrappers from prior turns.
if (obj.type === 'user' && obj.message?.content) {
if (obj.type === 'user' && isRecord(obj.message) && Array.isArray(obj.message.content)) {
for (const block of obj.message.content) {
if (!isRecord(block)) continue;
if (block.type === 'tool_result') {
onEvent({
type: 'tool_result',
@ -157,16 +166,16 @@ export function createClaudeStreamHandler(onEvent) {
}
}
function handleStreamEvent(ev) {
function handleStreamEvent(ev: Record<string, unknown>) {
if (ev.type === 'message_start') {
currentMessageId = ev.message?.id ?? null;
currentMessageId = isRecord(ev.message) && typeof ev.message.id === 'string' ? ev.message.id : null;
if (typeof ev.ttft_ms === 'number') {
onEvent({ type: 'status', label: 'streaming', ttftMs: ev.ttft_ms });
}
return;
}
if (ev.type === 'content_block_start' && ev.content_block) {
if (ev.type === 'content_block_start' && isRecord(ev.content_block)) {
const key = blockKey(ev.index);
const block = ev.content_block;
blocks.set(key, { type: block.type, name: block.name, id: block.id, input: '' });
@ -176,7 +185,7 @@ export function createClaudeStreamHandler(onEvent) {
return;
}
if (ev.type === 'content_block_delta' && ev.delta) {
if (ev.type === 'content_block_delta' && isRecord(ev.delta)) {
const state = blocks.get(blockKey(ev.index));
const delta = ev.delta;
@ -207,11 +216,11 @@ export function createClaudeStreamHandler(onEvent) {
return { feed, flush };
}
function stringifyToolResult(content) {
function stringifyToolResult(content: unknown): string {
if (typeof content === 'string') return content;
if (Array.isArray(content)) {
return content
.map((c) => (c?.type === 'text' ? c.text : JSON.stringify(c)))
.map((c) => (isRecord(c) && c.type === 'text' ? String(c.text) : JSON.stringify(c)))
.join('\n');
}
return JSON.stringify(content);

View file

@ -134,7 +134,43 @@ for (let i = 0; i < argv.length; i++) {
}
}
startServer({ port, host }).then(url => {
startServer({ port, host, returnServer: true }).then((started) => {
const { url, server, shutdown } = started;
const closeTimeoutMs = 5_000;
const closeServer = () => new Promise((resolve) => {
let resolved = false;
const resolveOnce = () => {
if (resolved) return;
resolved = true;
resolve();
};
const idleTimer = setTimeout(() => {
server.closeIdleConnections?.();
}, Math.min(1_000, closeTimeoutMs));
const hardTimer = setTimeout(() => {
server.closeAllConnections?.();
resolveOnce();
}, closeTimeoutMs);
idleTimer.unref?.();
hardTimer.unref?.();
server.close(() => resolveOnce());
}).finally(() => {
server.closeIdleConnections?.();
});
let shuttingDown = false;
const stop = () => {
if (shuttingDown) {
process.exit(0);
}
shuttingDown = true;
const closePromise = closeServer();
const shutdownPromise = Promise.resolve().then(() => shutdown?.());
void Promise.resolve()
.then(() => Promise.allSettled([shutdownPromise, closePromise]))
.finally(() => process.exit(0));
};
process.on('SIGINT', stop);
process.on('SIGTERM', stop);
console.log(`[od] listening on ${url}`);
if (open) {
const opener = process.platform === 'darwin' ? 'open'

View file

@ -13,10 +13,8 @@
// discover that the API key, model, base URL, or CLI is broken.
//
// The streaming counterpart for chat lives in `server.ts` under the
// `/api/proxy/*/stream` routes; this module deliberately duplicates the
// small URL/redaction helpers rather than restructure those routes (the
// chat path is the hot path and keeping changes here local protects it
// from accidental regressions).
// `/api/proxy/*/stream` routes; both paths share the base URL policy from
// contracts so Settings and daemon-side checks reject the same hosts.
import { spawn } from 'node:child_process';
import { promises as fsp } from 'node:fs';
@ -34,14 +32,19 @@ import { createClaudeStreamHandler } from './claude-stream.js';
import { createCopilotStreamHandler } from './copilot-stream.js';
import { createJsonEventStreamHandler } from './json-event-stream.js';
import { agentCliEnvForAgent, validateAgentCliEnv } from './app-config.js';
import type {
AgentTestRequest,
ConnectionTestKind,
ConnectionTestProtocol,
ConnectionTestResponse,
ProviderTestRequest,
import {
isLoopbackApiHost,
validateBaseUrl,
type AgentTestRequest,
type ConnectionTestKind,
type ConnectionTestProtocol,
type ConnectionTestResponse,
type ParsedBaseUrl,
type ProviderTestRequest,
} from '@open-design/contracts/api/connectionTest';
export { validateBaseUrl } from '@open-design/contracts/api/connectionTest';
// Aggressive but not punitive — happy paths usually return in under 2 s.
const PROVIDER_TIMEOUT_MS = 12_000;
// CLI boot time is dominated by adapter auth/session restore; the heavy
@ -86,105 +89,6 @@ export function redactSecrets(
type ProviderConnectionInput = ProviderTestRequest & { signal?: AbortSignal };
type AgentConnectionInput = AgentTestRequest & { signal?: AbortSignal };
function normalizeBracketedIpv6(hostname: string): string {
return hostname.startsWith('[') && hostname.endsWith(']')
? hostname.slice(1, -1).toLowerCase()
: hostname.toLowerCase();
}
function parseIpv4(hostname: string): [number, number, number, number] | null {
const parts = hostname.split('.');
if (parts.length !== 4) return null;
const parsed = parts.map((part) => {
if (!/^\d{1,3}$/.test(part)) return null;
const value = Number(part);
return value >= 0 && value <= 255 ? value : null;
});
if (parsed.some((part) => part === null)) return null;
return parsed as [number, number, number, number];
}
function isLoopbackIpv4(hostname: string): boolean {
const parts = parseIpv4(hostname);
return Boolean(parts && parts[0] === 127);
}
function isPrivateIpv4(hostname: string): boolean {
const parts = parseIpv4(hostname);
if (!parts) return false;
const [a, b] = parts;
return (
(a === 169 && b === 254) ||
a === 10 ||
(a === 192 && b === 168) ||
(a === 172 && b >= 16 && b <= 31)
);
}
function ipv4MappedToDotted(hostname: string): string | null {
const host = normalizeBracketedIpv6(hostname);
const mapped = /^::ffff:(.+)$/i.exec(host)?.[1];
if (!mapped) return null;
if (parseIpv4(mapped.toLowerCase())) return mapped.toLowerCase();
const hexParts = mapped.split(':');
if (
hexParts.length !== 2 ||
!hexParts.every((part) => /^[0-9a-f]{1,4}$/i.test(part))
) {
return null;
}
const hi = hexParts[0];
const lo = hexParts[1];
if (!hi || !lo) return null;
const value =
(Number.parseInt(hi, 16) << 16) |
Number.parseInt(lo, 16);
return [
(value >>> 24) & 255,
(value >>> 16) & 255,
(value >>> 8) & 255,
value & 255,
].join('.');
}
function isLoopbackHost(hostname: string): boolean {
const host = normalizeBracketedIpv6(hostname);
if (host === 'localhost' || host === '::1') return true;
if (isLoopbackIpv4(host)) return true;
const mapped = ipv4MappedToDotted(host);
return Boolean(mapped && isLoopbackIpv4(mapped));
}
function isBlockedInternalHost(hostname: string): boolean {
const host = normalizeBracketedIpv6(hostname);
if (isPrivateIpv4(host)) return true;
if (/^f[cd][0-9a-f]{2}:/i.test(host)) return true;
if (/^fe[89ab][0-9a-f]:/i.test(host)) return true;
const mapped = ipv4MappedToDotted(host);
return Boolean(mapped && isPrivateIpv4(mapped));
}
export function validateBaseUrl(baseUrl: string): {
parsed?: URL;
error?: string;
forbidden?: boolean;
} {
let parsed: URL;
try {
parsed = new URL(String(baseUrl).replace(/\/+$/, ''));
} catch {
return { error: 'Invalid baseUrl' };
}
if (!['http:', 'https:'].includes(parsed.protocol)) {
return { error: 'Only http/https allowed' };
}
const hostname = parsed.hostname.toLowerCase();
if (!isLoopbackHost(hostname) && isBlockedInternalHost(hostname)) {
return { error: 'Internal IPs blocked', forbidden: true };
}
return { parsed };
}
function appendVersionedApiPath(baseUrl: string, suffix: string): string {
const url = new URL(baseUrl);
const pathname = url.pathname.replace(/\/+$/, '');
@ -336,11 +240,11 @@ function networkErrorToKind(err: unknown): ConnectionTestKind {
async function validateLocalOpenAiModel(
input: ProviderTestRequest,
parsed: URL,
parsed: ParsedBaseUrl,
signal: AbortSignal,
start: number,
): Promise<ConnectionTestResponse | null> {
if (input.protocol !== 'openai' || !isLoopbackHost(parsed.hostname)) {
if (input.protocol !== 'openai' || !isLoopbackApiHost(parsed.hostname)) {
return null;
}
@ -351,6 +255,7 @@ async function validateLocalOpenAiModel(
method: 'GET',
headers: { authorization: `Bearer ${String(input.apiKey)}` },
signal,
redirect: 'error',
});
} catch {
// Local OpenAI-compatible servers vary; if model listing is unavailable,
@ -560,6 +465,7 @@ export async function testProviderConnection(
headers: call.headers,
body: JSON.stringify(call.body),
signal: controller.signal,
redirect: 'error',
});
const latencyMs = Date.now() - start;
if (response.ok) {
@ -588,7 +494,7 @@ export async function testProviderConnection(
input.protocol,
data,
model,
isLoopbackHost(validated.parsed.hostname),
isLoopbackApiHost(validated.parsed.hostname),
);
if (completion.kind) {
const detail = redactSecrets(completion.detail ?? '', [input.apiKey]);
@ -865,7 +771,6 @@ function attachAgentStreamHandlers(
model: model ?? null,
send,
imagePaths: [],
uploadRoot: undefined,
});
} else if (def.streamFormat === 'acp-json-rpc') {
acpSession = attachAcpSession({

View file

@ -43,6 +43,9 @@ export interface ConnectorDetail {
status: ConnectorStatus;
accountLabel?: string;
tools: ConnectorToolDetail[];
toolCount?: number;
toolsNextCursor?: string;
toolsHasMore?: boolean;
featuredToolNames?: string[];
minimumApproval?: ConnectorToolApproval;
lastError?: string;
@ -63,6 +66,11 @@ export interface ConnectorCatalogDefinition {
tools: ConnectorCatalogToolDefinition[];
/** The complete allowlist of callable tool names for this connector. */
allowedToolNames: string[];
/** Display-only count of provider tools. This may be known before tool schemas are hydrated. */
toolCount?: number;
/** Preview pagination state for hydrated tool definitions. Execution code must not rely on partial pages. */
toolsNextCursor?: string;
toolsHasMore?: boolean;
/** How the connector is made available. `none` and `local` connectors require no user OAuth state. */
authentication?: 'local' | 'none' | 'oauth' | 'composio';
/** Provider toolkit slug used by external connector providers such as Composio. */
@ -173,6 +181,9 @@ export function connectorDefinitionToDetail(definition: ConnectorCatalogDefiniti
...(definition.description === undefined ? {} : { description: definition.description }),
status: definition.disabled ? 'disabled' : 'available',
tools: definition.tools.map((tool) => toolDefinitionToDetail(tool)),
...(definition.toolCount === undefined ? {} : { toolCount: definition.toolCount }),
...(definition.toolsNextCursor === undefined ? {} : { toolsNextCursor: definition.toolsNextCursor }),
...(definition.toolsHasMore === undefined ? {} : { toolsHasMore: definition.toolsHasMore }),
...(definition.featuredToolNames === undefined
? {}
: { featuredToolNames: [...definition.featuredToolNames] }),

View file

@ -3,6 +3,7 @@ import path from 'node:path';
export interface ComposioConfig {
apiKey: string;
authConfigIds: Record<string, string>;
}
export interface PublicComposioConfig {
@ -35,14 +36,42 @@ export function writeComposioConfig(input: unknown): PublicComposioConfig {
? input as Record<string, unknown>
: {};
const hasApiKey = Object.prototype.hasOwnProperty.call(record, 'apiKey');
const hasAuthConfigIds = Object.prototype.hasOwnProperty.call(record, 'authConfigIds');
const apiKeyInput = normalizeOptionalString(record.apiKey) ?? '';
const nextApiKey = hasApiKey ? apiKeyInput : prior.apiKey;
const apiKeyChanged = prior.apiKey !== nextApiKey;
const next = normalizeComposioConfig({
apiKey: hasApiKey ? apiKeyInput : prior.apiKey,
apiKey: nextApiKey,
authConfigIds: apiKeyChanged ? {} : hasAuthConfigIds ? record.authConfigIds : prior.authConfigIds,
});
writeRawConfig(next);
return readPublicComposioConfig();
}
export function setComposioAuthConfigId(connectorId: string, authConfigId: string): void {
const normalizedConnectorId = normalizeOptionalString(connectorId);
const normalizedAuthConfigId = normalizeOptionalString(authConfigId);
if (!normalizedConnectorId || !normalizedAuthConfigId) return;
const prior = readComposioConfig();
writeRawConfig(normalizeComposioConfig({
apiKey: prior.apiKey,
authConfigIds: {
...prior.authConfigIds,
[normalizedConnectorId]: normalizedAuthConfigId,
},
}));
}
export function deleteComposioAuthConfigId(connectorId: string): void {
const normalizedConnectorId = normalizeOptionalString(connectorId);
if (!normalizedConnectorId) return;
const prior = readComposioConfig();
if (prior.authConfigIds[normalizedConnectorId] === undefined) return;
const authConfigIds = { ...prior.authConfigIds };
delete authConfigIds[normalizedConnectorId];
writeRawConfig(normalizeComposioConfig({ apiKey: prior.apiKey, authConfigIds }));
}
function readRawConfig(): unknown {
try {
return JSON.parse(fs.readFileSync(configFilePath, 'utf8')) as unknown;
@ -66,9 +95,21 @@ function normalizeComposioConfig(value: unknown): ComposioConfig {
: {};
return {
apiKey: normalizeOptionalString(raw.apiKey) ?? '',
authConfigIds: normalizeAuthConfigIds(raw.authConfigIds),
};
}
function normalizeOptionalString(value: unknown): string | undefined {
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined;
}
function normalizeAuthConfigIds(value: unknown): Record<string, string> {
if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
const next: Record<string, string> = {};
for (const [connectorId, authConfigId] of Object.entries(value as Record<string, unknown>)) {
const normalizedConnectorId = normalizeOptionalString(connectorId);
const normalizedAuthConfigId = normalizeOptionalString(authConfigId);
if (normalizedConnectorId && normalizedAuthConfigId) next[normalizedConnectorId] = normalizedAuthConfigId;
}
return next;
}

View file

@ -15,6 +15,8 @@ export interface ComposioToolkitMetadata {
description: string;
/** Preferred category tag for the connector card. */
category: string;
/** Snapshot count for first paint before live toolkit metadata loads. */
toolCount?: number;
}
export const COMPOSIO_TOOLKIT_METADATA: Record<string, ComposioToolkitMetadata> = {
@ -23,6 +25,7 @@ export const COMPOSIO_TOOLKIT_METADATA: Record<string, ComposioToolkitMetadata>
description:
'Browse repositories, read issues and pull requests, inspect commits, and search code across GitHub.',
category: 'Developer',
toolCount: 2,
},
GITLAB: {
description:
@ -102,6 +105,7 @@ export const COMPOSIO_TOOLKIT_METADATA: Record<string, ComposioToolkitMetadata>
APIFY_MCP: {
description: 'Run Apify actors to scrape, crawl, and enrich data for live artifacts.',
category: 'Automation',
toolCount: 8,
},
TAVILY_MCP: {
description: 'Run Tavily web search and extraction for research-grounded artifacts.',
@ -121,6 +125,7 @@ export const COMPOSIO_TOOLKIT_METADATA: Record<string, ComposioToolkitMetadata>
description:
'Search Notion pages and databases, read page content, and pull structured records into artifacts.',
category: 'Productivity',
toolCount: 48,
},
GOOGLEDOCS: {
description: 'Read Google Docs content and comments to source text for live artifacts.',
@ -143,6 +148,7 @@ export const COMPOSIO_TOOLKIT_METADATA: Record<string, ComposioToolkitMetadata>
GOOGLEDRIVE: {
description: 'Search and read files and folders stored in Google Drive.',
category: 'Storage',
toolCount: 2,
},
DROPBOX: {
description: 'Search and read files stored in Dropbox for document-grounded artifacts.',
@ -522,6 +528,7 @@ export const COMPOSIO_TOOLKIT_METADATA: Record<string, ComposioToolkitMetadata>
TWITTER: {
description: 'Read Twitter/X timelines, tweets, users, and searches.',
category: 'Social',
toolCount: 72,
},
FACEBOOK: {
description: 'Read Facebook pages, posts, and insights.',
@ -602,6 +609,7 @@ export const COMPOSIO_TOOLKIT_METADATA: Record<string, ComposioToolkitMetadata>
CLOCKIFY: {
description: 'Read Clockify time entries, projects, and reports.',
category: 'Time tracking',
toolCount: 75,
},
HARVEST: {
description: 'Read Harvest time entries, projects, and invoices.',
@ -658,6 +666,7 @@ export const COMPOSIO_TOOLKIT_METADATA: Record<string, ComposioToolkitMetadata>
AIRTABLE: {
description: 'Query Airtable bases, tables, and records for structured data artifacts.',
category: 'Database',
toolCount: 25,
},
CONTENTFUL: {
description: 'Read Contentful content types, entries, and assets.',
@ -696,6 +705,7 @@ export const COMPOSIO_TOOLKIT_METADATA: Record<string, ComposioToolkitMetadata>
CANVAS: {
description: 'Read Canvas LMS courses, assignments, and submissions.',
category: 'Education',
toolCount: 574,
},
D2LBRIGHTSPACE: {
description: 'Read D2L Brightspace courses, enrollments, and gradebooks.',

View file

@ -4,7 +4,7 @@ import path from 'node:path';
import type { BoundedJsonObject, BoundedJsonValue } from '../live-artifacts/schema.js';
import { defineConnectorTool, type ConnectorCatalogDefinition, type ConnectorCatalogToolDefinition } from './catalog.js';
import { readComposioConfig } from './composio-config.js';
import { deleteComposioAuthConfigId, readComposioConfig, setComposioAuthConfigId } from './composio-config.js';
import { COMPOSIO_CURATION_OVERLAY } from './composio-curation.js';
import { getComposioToolkitMetadata } from './composio-descriptions.js';
import { ConnectorServiceError, type ConnectorCredentialMaterial } from './service.js';
@ -14,6 +14,7 @@ const DEFAULT_COMPOSIO_TIMEOUT_MS = 30_000;
const DEFAULT_COMPOSIO_USER_ID = 'open-design-local-user';
const OAUTH_STATE_TTL_MS = 10 * 60 * 1000;
const DISCOVERY_CACHE_TTL_MS = 60_000;
const CUSTOM_AUTH_REQUIRED_MESSAGE = 'Composio does not have managed credentials for this toolkit.';
const PERSISTED_CATALOG_REFRESH_MS = 24 * 60 * 60 * 1000;
interface ComposioToolkitCatalogEntry {
@ -63,6 +64,7 @@ const FEATURED_COMPOSIO_CATALOG: ConnectorCatalogDefinition[] = [
allowedToolNames: ['github.github_search_repositories', 'github.github_get_issue'],
featuredToolNames: ['github.github_search_repositories', 'github.github_get_issue'],
minimumApproval: 'auto',
toolCount: 2,
},
{
id: 'notion',
@ -95,6 +97,7 @@ const FEATURED_COMPOSIO_CATALOG: ConnectorCatalogDefinition[] = [
allowedToolNames: ['notion.notion_search', 'notion.notion_fetch_database'],
featuredToolNames: ['notion.notion_search', 'notion.notion_fetch_database'],
minimumApproval: 'auto',
toolCount: 48,
},
{
id: 'google_drive',
@ -127,6 +130,7 @@ const FEATURED_COMPOSIO_CATALOG: ConnectorCatalogDefinition[] = [
allowedToolNames: ['google_drive.googledrive_search', 'google_drive.googledrive_get_file'],
featuredToolNames: ['google_drive.googledrive_search', 'google_drive.googledrive_get_file'],
minimumApproval: 'auto',
toolCount: 2,
},
];
@ -377,6 +381,12 @@ interface ComposioToolResponse {
toolkit?: { slug?: unknown };
}
interface ComposioToolsPage {
items: ComposioToolResponse[];
nextCursor?: string;
totalItems?: number;
}
interface ComposioToolExecuteResponse {
data?: unknown;
error?: unknown;
@ -409,13 +419,24 @@ export interface ComposioConnectionCompletion {
credentials: ConnectorCredentialMaterial;
}
interface ComposioAuthConfigResolution {
authConfigId: string;
fromCache: boolean;
}
export type ComposioAuthConfigPrepareResult =
| { status: 'ready'; authConfigId: string }
| { status: 'custom_required'; message: string }
| { status: 'error'; message: string };
export class ComposioConnectorProvider {
private discoveredAuthConfigIds: Record<string, string> | undefined;
private readonly locallyCreatedAuthConfigs = new Map<string, { authConfigId: string; toolkitSlug: string }>();
private definitionsCache: { definitions: ConnectorCatalogDefinition[]; expiresAtMs: number } | undefined;
private definitionsPromise: Promise<ConnectorCatalogDefinition[]> | undefined;
private readonly definitionsCache = new Map<string, { definitions: ConnectorCatalogDefinition[]; expiresAtMs: number }>();
private readonly definitionsPromises = new Map<string, Promise<ConnectorCatalogDefinition[]>>();
private definitionsGeneration = 0;
private readonly authConfigCreationPromises = new Map<string, Promise<string | undefined>>();
private readonly authConfigCreationPromises = new Map<string, Promise<string>>();
private readonly unsupportedManagedAuthConfigs = new Map<string, string>();
private readonly pendingConnections = new Map<string, ComposioPendingConnection>();
private persistedDefinitions: ConnectorCatalogDefinition[] | undefined;
private persistedFetchedAt: string | undefined;
@ -423,7 +444,7 @@ export class ComposioConnectorProvider {
private refreshTimeout: NodeJS.Timeout | undefined;
isConfigured(definition: ConnectorCatalogDefinition): boolean {
return Boolean(this.getApiKey() && this.discoveredAuthConfigIds?.[definition.id]);
return Boolean(this.getApiKey() && (this.getPersistedAuthConfigId(definition.id) || this.discoveredAuthConfigIds?.[definition.id]));
}
clearDiscoveryCache(): void {
@ -431,6 +452,7 @@ export class ComposioConnectorProvider {
this.locallyCreatedAuthConfigs.clear();
this.invalidateDefinitionsCache();
this.authConfigCreationPromises.clear();
this.unsupportedManagedAuthConfigs.clear();
}
configureCatalogCache(dataDir: string): void {
@ -480,34 +502,37 @@ export class ComposioConnectorProvider {
private invalidateDefinitionsCache(): void {
this.definitionsGeneration += 1;
this.definitionsCache = undefined;
this.definitionsPromise = undefined;
this.definitionsCache.clear();
this.definitionsPromises.clear();
}
async listDefinitions(signal?: AbortSignal): Promise<ConnectorCatalogDefinition[]> {
async listDefinitions(signal?: AbortSignal, options: { hydrateTools?: boolean } = {}): Promise<ConnectorCatalogDefinition[]> {
const cacheKey = options.hydrateTools ? 'hydrated' : 'metadata';
const now = Date.now();
if (this.definitionsCache && this.definitionsCache.expiresAtMs > now) {
return this.definitionsCache.definitions;
const cached = this.definitionsCache.get(cacheKey);
if (cached && cached.expiresAtMs > now) {
return cached.definitions;
}
if (this.definitionsPromise) return this.definitionsPromise;
const existing = this.definitionsPromises.get(cacheKey);
if (existing) return existing;
const generation = this.definitionsGeneration;
const promise = this.fetchDefinitions(signal)
const promise = this.fetchDefinitions(signal, Boolean(options.hydrateTools))
.then((definitions) => {
if (this.definitionsGeneration === generation) {
this.definitionsCache = { definitions, expiresAtMs: Date.now() + DISCOVERY_CACHE_TTL_MS };
this.definitionsCache.set(cacheKey, { definitions, expiresAtMs: Date.now() + DISCOVERY_CACHE_TTL_MS });
}
this.setPersistedDefinitions(definitions, new Date().toISOString());
return definitions;
})
.finally(() => {
if (this.definitionsPromise === promise && this.definitionsGeneration === generation) this.definitionsPromise = undefined;
if (this.definitionsPromises.get(cacheKey) === promise && this.definitionsGeneration === generation) this.definitionsPromises.delete(cacheKey);
});
this.definitionsPromise = promise;
this.definitionsPromises.set(cacheKey, promise);
return promise;
}
private async fetchDefinitions(signal?: AbortSignal): Promise<ConnectorCatalogDefinition[]> {
private async fetchDefinitions(signal?: AbortSignal, hydrateTools = false): Promise<ConnectorCatalogDefinition[]> {
const apiKey = this.getApiKey();
const authConfigs = apiKey ? await this.listAuthConfigsSafe(signal) : [];
const configuredByConnectorId = new Map<string, { authConfigId: string; toolkitSlug: string }>();
@ -532,7 +557,7 @@ export class ComposioConnectorProvider {
const configuredEntry = configuredByConnectorId.get(staticDefinition.id);
const toolkitSlug = configuredEntry?.toolkitSlug ?? staticDefinition.providerConnectorId ?? staticDefinition.id;
const toolkit = toolkitBySlug.get(normalizeComposioSlug(toolkitSlug));
return this.definitionFromToolkit(staticDefinition, toolkitSlug, toolkit, Boolean(apiKey), signal);
return this.definitionFromToolkit(staticDefinition, toolkitSlug, toolkit, Boolean(apiKey && hydrateTools), signal);
});
return definitions;
}
@ -595,30 +620,40 @@ export class ComposioConnectorProvider {
return undefined;
}
async getHydratedDefinition(connectorId: string, signal?: AbortSignal): Promise<ConnectorCatalogDefinition | undefined> {
const discovered = (await this.listDefinitions(signal, { hydrateTools: true })).find((definition) => definition.id === connectorId);
if (discovered) return discovered;
return undefined;
}
async getPreviewDefinition(connectorId: string, options: { toolsLimit: number; toolsCursor?: string; signal?: AbortSignal }): Promise<ConnectorCatalogDefinition | undefined> {
const metadataDefinition = (await this.listDefinitions(options.signal)).find((definition) => definition.id === connectorId);
if (!metadataDefinition) return undefined;
const toolkitSlug = metadataDefinition.providerConnectorId ?? metadataDefinition.id;
return this.definitionFromToolkit(metadataDefinition, toolkitSlug, undefined, true, options.signal, {
toolsLimit: options.toolsLimit,
...(options.toolsCursor === undefined ? {} : { toolsCursor: options.toolsCursor }),
});
}
async connect(definition: ConnectorCatalogDefinition, callbackUrl: string, signal?: AbortSignal): Promise<ComposioConnectionStart> {
this.pruneExpiredPendingConnections();
const authConfigId = await this.getOrCreateManagedAuthConfigId(definition, signal);
if (!authConfigId) {
throw new ConnectorServiceError('CONNECTOR_EXECUTION_FAILED', 'Composio auth config could not be created for this connector', 503, {
connectorId: definition.id,
setting: 'apiKey',
});
}
let authConfig = await this.getOrCreateManagedAuthConfigId(definition, signal);
const state = crypto.randomBytes(24).toString('base64url');
const expiresAtMs = Date.now() + OAUTH_STATE_TTL_MS;
const expiresAt = new Date(expiresAtMs).toISOString();
const response = await this.requestJson<ComposioConnectedAccountResponse>('/api/v3.1/connected_accounts/link', {
method: 'POST',
body: JSON.stringify({
auth_config_id: authConfigId,
user_id: this.getUserId(),
connection_data: { state_prefix: state },
callback_url: appendOAuthStateToCallbackUrl(callbackUrl, state),
}),
...(signal === undefined ? {} : { signal }),
});
const callbackUrlWithState = appendOAuthStateToCallbackUrl(callbackUrl, state);
let response: ComposioConnectedAccountResponse;
try {
response = await this.createConnectedAccountLink(authConfig.authConfigId, state, callbackUrlWithState, signal);
} catch (error) {
if (!authConfig.fromCache) throw error;
deleteComposioAuthConfigId(definition.id);
authConfig = await this.getOrCreateManagedAuthConfigId(definition, signal, { ignoreCache: true });
response = await this.createConnectedAccountLink(authConfig.authConfigId, state, callbackUrlWithState, signal);
}
const providerConnectionId = getComposioConnectionId(response);
const redirectUrl = getString(response.redirect_url) ?? getString(response.redirectUrl);
@ -626,7 +661,7 @@ export class ComposioConnectorProvider {
this.pendingConnections.set(state, { connectorId: definition.id, state, ...(providerConnectionId ? { providerConnectionId } : {}), expiresAtMs });
const validatedConnection = status === 'ACTIVE' && providerConnectionId
? await this.getValidatedConnectedAccount(definition, providerConnectionId, authConfigId, signal)
? await this.getValidatedConnectedAccount(definition, providerConnectionId, authConfig.authConfigId, signal)
: undefined;
if (validatedConnection) this.pendingConnections.delete(state);
@ -639,6 +674,35 @@ export class ComposioConnectorProvider {
};
}
async prepareAuthConfig(definition: ConnectorCatalogDefinition, signal?: AbortSignal): Promise<ComposioAuthConfigPrepareResult> {
if (definition.authentication !== 'composio') return { status: 'error', message: 'connector is not backed by Composio' };
const unsupported = this.unsupportedManagedAuthConfigs.get(definition.id);
if (unsupported) return { status: 'custom_required', message: unsupported };
try {
const resolution = await this.getOrCreateManagedAuthConfigId(definition, signal);
return { status: 'ready', authConfigId: resolution.authConfigId };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (isCustomAuthRequiredMessage(message)) {
const customMessage = normalizeCustomAuthRequiredMessage(message);
this.unsupportedManagedAuthConfigs.set(definition.id, customMessage);
return { status: 'custom_required', message: customMessage };
}
return { status: 'error', message };
}
}
cancelPendingConnections(connectorId: string): number {
this.pruneExpiredPendingConnections();
let cancelled = 0;
for (const [state, pending] of this.pendingConnections.entries()) {
if (pending.connectorId !== connectorId) continue;
this.pendingConnections.delete(state);
cancelled += 1;
}
return cancelled;
}
async completeConnection(input: { definition: ConnectorCatalogDefinition; state: string; providerConnectionId?: string; status?: string; signal?: AbortSignal }): Promise<ComposioConnectionCompletion> {
this.pruneExpiredPendingConnections();
@ -660,6 +724,8 @@ export class ComposioConnectorProvider {
}
const expectedAuthConfigId = await this.getAuthConfigId(input.definition, input.signal);
const response = await this.getValidatedConnectedAccount(input.definition, providerConnectionId, expectedAuthConfigId, input.signal);
const authConfigId = getString(response.auth_config?.id);
if (authConfigId) this.storeAuthConfigId(input.definition, authConfigId, getString(response.toolkit?.slug) ?? input.definition.providerConnectorId);
return this.connectionToCredentials(input.definition, providerConnectionId, response);
}
@ -733,30 +799,54 @@ export class ComposioConnectorProvider {
}
private async getAuthConfigId(definition: ConnectorCatalogDefinition, signal?: AbortSignal): Promise<string | undefined> {
const persisted = this.getPersistedAuthConfigId(definition.id);
if (persisted) return persisted;
if (!this.discoveredAuthConfigIds) this.discoveredAuthConfigIds = await this.discoverAuthConfigIds(signal);
return this.discoveredAuthConfigIds[definition.id];
}
private async getOrCreateManagedAuthConfigId(definition: ConnectorCatalogDefinition, signal?: AbortSignal): Promise<string | undefined> {
const existing = await this.getAuthConfigId(definition, signal);
if (existing) return existing;
private async getOrCreateManagedAuthConfigId(definition: ConnectorCatalogDefinition, signal?: AbortSignal, options: { ignoreCache?: boolean } = {}): Promise<ComposioAuthConfigResolution> {
if (!options.ignoreCache) {
const persisted = this.getPersistedAuthConfigId(definition.id);
if (persisted) {
return { authConfigId: persisted, fromCache: true };
}
const discovered = this.discoveredAuthConfigIds?.[definition.id];
if (discovered) {
return { authConfigId: discovered, fromCache: true };
}
}
const existing = await this.getAuthConfigIdForToolkit(definition, signal);
if (existing) {
this.storeAuthConfigId(definition, existing);
return { authConfigId: existing, fromCache: false };
}
const inFlight = this.authConfigCreationPromises.get(definition.id);
if (inFlight) return inFlight;
if (inFlight) {
const authConfigId = await inFlight;
return { authConfigId, fromCache: false };
}
const promise = this.createAndStoreManagedAuthConfigId(definition, signal)
.finally(() => {
if (this.authConfigCreationPromises.get(definition.id) === promise) this.authConfigCreationPromises.delete(definition.id);
});
this.authConfigCreationPromises.set(definition.id, promise);
return promise;
const authConfigId = await promise;
return { authConfigId, fromCache: false };
}
private async createAndStoreManagedAuthConfigId(definition: ConnectorCatalogDefinition, signal?: AbortSignal): Promise<string | undefined> {
private async createAndStoreManagedAuthConfigId(definition: ConnectorCatalogDefinition, signal?: AbortSignal): Promise<string> {
const created = await this.createManagedAuthConfig(definition, signal);
const authConfigId = getComposioAuthConfigId(created);
const toolkitSlug = getComposioToolkitSlug(created) ?? definition.providerConnectorId;
if (!authConfigId || !toolkitSlug) return undefined;
if (!authConfigId || !toolkitSlug) {
throw new ConnectorServiceError('CONNECTOR_EXECUTION_FAILED', 'Composio auth config response was missing an id or toolkit slug', 502, {
connectorId: definition.id,
});
}
const connectorId = connectorIdForToolkitSlug(toolkitSlug);
if (connectorId !== definition.id) {
@ -766,11 +856,7 @@ export class ComposioConnectorProvider {
});
}
this.discoveredAuthConfigIds = {
...(this.discoveredAuthConfigIds ?? {}),
[definition.id]: authConfigId,
};
this.locallyCreatedAuthConfigs.set(definition.id, { authConfigId, toolkitSlug });
this.storeAuthConfigId(definition, authConfigId, toolkitSlug);
this.invalidateDefinitionsCache();
return authConfigId;
}
@ -790,6 +876,49 @@ export class ComposioConnectorProvider {
});
}
private async getAuthConfigIdForToolkit(definition: ConnectorCatalogDefinition, signal?: AbortSignal): Promise<string | undefined> {
const toolkitSlug = definition.providerConnectorId;
if (!toolkitSlug) {
throw new ConnectorServiceError('CONNECTOR_EXECUTION_FAILED', 'Composio connector is missing a toolkit slug', 500, { connectorId: definition.id });
}
const items = await this.listAuthConfigsSafe(signal, toolkitSlug);
for (const item of items) {
const authConfigId = getComposioAuthConfigId(item);
const itemToolkitSlug = getComposioToolkitSlug(item) ?? toolkitSlug;
const status = getString(item.status)?.toUpperCase();
if (!authConfigId || (status && status !== 'ENABLED')) continue;
if (connectorIdForToolkitSlug(itemToolkitSlug) !== definition.id) continue;
return authConfigId;
}
return undefined;
}
private async createConnectedAccountLink(authConfigId: string, state: string, callbackUrl: string, signal?: AbortSignal): Promise<ComposioConnectedAccountResponse> {
return this.requestJson<ComposioConnectedAccountResponse>('/api/v3.1/connected_accounts/link', {
method: 'POST',
body: JSON.stringify({
auth_config_id: authConfigId,
user_id: this.getUserId(),
connection_data: { state_prefix: state },
callback_url: callbackUrl,
}),
...(signal === undefined ? {} : { signal }),
});
}
private getPersistedAuthConfigId(connectorId: string): string | undefined {
return getString(readComposioConfig().authConfigIds[connectorId]);
}
private storeAuthConfigId(definition: ConnectorCatalogDefinition, authConfigId: string, toolkitSlug = definition.providerConnectorId): void {
this.discoveredAuthConfigIds = {
...(this.discoveredAuthConfigIds ?? {}),
[definition.id]: authConfigId,
};
if (toolkitSlug) this.locallyCreatedAuthConfigs.set(definition.id, { authConfigId, toolkitSlug });
setComposioAuthConfigId(definition.id, authConfigId);
}
private async discoverAuthConfigIds(signal?: AbortSignal): Promise<Record<string, string>> {
if (!this.getApiKey()) return {};
const items = await this.listAuthConfigsSafe(signal);
@ -805,17 +934,20 @@ export class ComposioConnectorProvider {
return discovered;
}
private async listAuthConfigs(signal?: AbortSignal): Promise<ComposioAuthConfigResponse[]> {
const response = await this.request('/api/v3/auth_configs', { method: 'GET', ...(signal === undefined ? {} : { signal }) });
private async listAuthConfigs(signal?: AbortSignal, toolkitSlug?: string): Promise<ComposioAuthConfigResponse[]> {
const path = toolkitSlug
? `/api/v3/auth_configs?${new URLSearchParams({ toolkit_slug: toolkitSlug }).toString()}`
: '/api/v3/auth_configs';
const response = await this.request(path, { method: 'GET', ...(signal === undefined ? {} : { signal }) });
if (!response.ok) return [];
const payload = await response.json() as { items?: unknown; data?: unknown };
const items = Array.isArray(payload.items) ? payload.items : Array.isArray(payload.data) ? payload.data : [];
return items.filter((item): item is ComposioAuthConfigResponse => Boolean(item && typeof item === 'object' && !Array.isArray(item)));
}
private async listAuthConfigsSafe(signal?: AbortSignal): Promise<ComposioAuthConfigResponse[]> {
private async listAuthConfigsSafe(signal?: AbortSignal, toolkitSlug?: string): Promise<ComposioAuthConfigResponse[]> {
try {
return await this.listAuthConfigs(signal);
return await this.listAuthConfigs(signal, toolkitSlug);
} catch {
return [];
}
@ -837,13 +969,27 @@ export class ComposioConnectorProvider {
}
}
private async listTools(toolkitSlug: string, signal?: AbortSignal): Promise<ComposioToolResponse[]> {
const searchParams = new URLSearchParams({ toolkit_slug: toolkitSlug.toLowerCase(), limit: '1000' });
const response = await this.request(`/api/v3.1/tools?${searchParams.toString()}`, { method: 'GET', ...(signal === undefined ? {} : { signal }) });
if (!response.ok) return [];
const payload = await response.json() as { items?: unknown; data?: unknown };
private async listToolsPage(toolkitSlug: string, options: { limit?: number; cursor?: string; signal?: AbortSignal } = {}): Promise<ComposioToolsPage> {
const searchParams = new URLSearchParams({ toolkit_slug: toolkitSlug.toLowerCase(), limit: String(options.limit ?? 1000) });
if (options.cursor) searchParams.set('cursor', options.cursor);
const response = await this.request(`/api/v3.1/tools?${searchParams.toString()}`, { method: 'GET', ...(options.signal === undefined ? {} : { signal: options.signal }) });
if (!response.ok) {
const message = await getComposioErrorMessage(response);
throw new ConnectorServiceError('CONNECTOR_EXECUTION_FAILED', message ?? `Composio tools request failed with HTTP ${response.status}`, response.status === 401 ? 401 : 502, { httpStatus: response.status });
}
const payload = await response.json() as { items?: unknown; data?: unknown; next_cursor?: unknown; nextCursor?: unknown; total_items?: unknown; totalItems?: unknown };
const items = Array.isArray(payload.items) ? payload.items : Array.isArray(payload.data) ? payload.data : [];
return items.filter((item): item is ComposioToolResponse => Boolean(item && typeof item === 'object' && !Array.isArray(item)));
const nextCursor = getString(payload.next_cursor) ?? getString(payload.nextCursor);
const totalItems = getNonNegativeInteger(payload.total_items) ?? getNonNegativeInteger(payload.totalItems);
return {
items: items.filter((item): item is ComposioToolResponse => Boolean(item && typeof item === 'object' && !Array.isArray(item))),
...(nextCursor === undefined ? {} : { nextCursor }),
...(totalItems === undefined ? {} : { totalItems }),
};
}
private async listTools(toolkitSlug: string, signal?: AbortSignal): Promise<ComposioToolResponse[]> {
return (await this.listToolsPage(toolkitSlug, { limit: 1000, ...(signal === undefined ? {} : { signal }) })).items;
}
private async listToolsSafe(toolkitSlug: string, signal?: AbortSignal): Promise<ComposioToolResponse[]> {
@ -854,10 +1000,24 @@ export class ComposioConnectorProvider {
}
}
private async definitionFromToolkit(staticDefinition: ConnectorCatalogDefinition, toolkitSlug: string, toolkit: ComposioToolkitResponse | undefined, hydrateTools: boolean, signal?: AbortSignal): Promise<ConnectorCatalogDefinition> {
private async definitionFromToolkit(
staticDefinition: ConnectorCatalogDefinition,
toolkitSlug: string,
toolkit: ComposioToolkitResponse | undefined,
hydrateTools: boolean,
signal?: AbortSignal,
toolPageOptions: { toolsLimit?: number; toolsCursor?: string } = {},
): Promise<ConnectorCatalogDefinition> {
const connectorId = staticDefinition.id;
const toolPage = hydrateTools && toolPageOptions.toolsLimit !== undefined
? await this.listToolsPage(toolkitSlug, {
limit: toolPageOptions.toolsLimit,
...(toolPageOptions.toolsCursor === undefined ? {} : { cursor: toolPageOptions.toolsCursor }),
...(signal === undefined ? {} : { signal }),
})
: undefined;
const liveTools = hydrateTools
? (await this.listToolsSafe(toolkitSlug, signal))
? (toolPage?.items ?? await this.listToolsSafe(toolkitSlug, signal))
.filter((tool) => {
const toolToolkitSlug = getString(tool.toolkit?.slug);
return !toolToolkitSlug || normalizeComposioSlug(toolToolkitSlug) === normalizeComposioSlug(toolkitSlug);
@ -878,6 +1038,8 @@ export class ComposioConnectorProvider {
const category = firstCategoryName(toolkit?.meta?.categories) ?? firstCategoryName(toolkit?.categories) ?? staticDefinition.category;
const liveDescription = getComposioToolkitDescription(toolkit);
const description = liveDescription ?? staticDefinition.description;
const liveToolCount = getComposioToolkitToolCount(toolkit);
const toolCount = toolPage?.totalItems ?? liveToolCount ?? staticDefinition.toolCount ?? (tools.length > 0 ? tools.length : undefined);
return {
...staticDefinition,
id: connectorId,
@ -886,6 +1048,9 @@ export class ComposioConnectorProvider {
category,
...(description === undefined ? {} : { description }),
tools,
...(toolCount === undefined ? {} : { toolCount }),
...(toolPage?.nextCursor === undefined ? {} : { toolsNextCursor: toolPage.nextCursor }),
...(toolPage === undefined ? {} : { toolsHasMore: toolPage.nextCursor !== undefined }),
allowedToolNames,
...(staticDefinition.featuredToolNames === undefined
? tools.length > 0 ? { featuredToolNames: tools.slice(0, 3).map((tool) => tool.name) } : {}
@ -1023,6 +1188,7 @@ function createComposioCatalogDefinition(toolkit: ComposioToolkitCatalogEntry):
tools: [],
allowedToolNames: [],
minimumApproval: 'auto',
...(curated?.toolCount === undefined ? {} : { toolCount: curated.toolCount }),
};
}
@ -1048,6 +1214,7 @@ function cloneConnectorDefinition(definition: ConnectorCatalogDefinition): Conne
...(tool.providerToolId === undefined ? {} : { providerToolId: tool.providerToolId }),
})),
allowedToolNames: [...definition.allowedToolNames],
...(definition.toolCount === undefined ? {} : { toolCount: definition.toolCount }),
...(definition.featuredToolNames === undefined ? {} : { featuredToolNames: [...definition.featuredToolNames] }),
};
}
@ -1073,6 +1240,9 @@ function normalizePersistedConnectorDefinition(value: unknown): ConnectorCatalog
}
if (typeof record.providerConnectorId === 'string') definition.providerConnectorId = record.providerConnectorId;
if (Array.isArray(record.featuredToolNames)) definition.featuredToolNames = record.featuredToolNames.filter((item): item is string => typeof item === 'string');
if (typeof record.toolCount === 'number' && Number.isFinite(record.toolCount) && record.toolCount >= 0) {
definition.toolCount = record.toolCount;
}
if (record.minimumApproval === 'auto' || record.minimumApproval === 'confirm' || record.minimumApproval === 'disabled') {
definition.minimumApproval = record.minimumApproval;
}
@ -1144,6 +1314,10 @@ function getString(value: unknown): string | undefined {
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined;
}
function getNonNegativeInteger(value: unknown): number | undefined {
return typeof value === 'number' && Number.isInteger(value) && value >= 0 ? value : undefined;
}
function getStringArray(value: unknown): string[] {
if (!Array.isArray(value)) return [];
return value.filter((item): item is string => typeof item === 'string' && item.trim().length > 0).map((item) => item.trim());
@ -1169,6 +1343,10 @@ function getComposioToolkitDescription(toolkit: ComposioToolkitResponse | undefi
return description;
}
function getComposioToolkitToolCount(toolkit: ComposioToolkitResponse | undefined): number | undefined {
return getNonNegativeInteger(toolkit?.meta?.tools_count) ?? getNonNegativeInteger(toolkit?.meta?.toolsCount);
}
function isGenericComposioDescription(description: string): boolean {
return /^connect to .+ through composio\.?$/i.test(description.trim())
|| /^.+ integration via composio\.?$/i.test(description.trim());
@ -1257,12 +1435,27 @@ function firstCategoryName(value: unknown): string | undefined {
return undefined;
}
function isCustomAuthRequiredMessage(message: string): boolean {
return /default auth config not found/i.test(message) || /does not have managed credentials/i.test(message);
}
function normalizeCustomAuthRequiredMessage(message: string): string {
return message || CUSTOM_AUTH_REQUIRED_MESSAGE;
}
async function getComposioErrorMessage(response: Response): Promise<string | undefined> {
try {
const payload = await response.json() as unknown;
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return undefined;
const record = payload as Record<string, unknown>;
return getString(record.message) ?? getString(record.error) ?? getString(record.detail);
const error = record.error && typeof record.error === 'object' && !Array.isArray(record.error)
? record.error as Record<string, unknown>
: undefined;
return getString(record.message)
?? getString(error?.message)
?? getString(record.error)
?? getString(record.detail)
?? getString(error?.suggested_fix);
} catch {
return undefined;
}

View file

@ -527,7 +527,10 @@ export function registerConnectorRoutes(app: Express, options: RegisterConnector
const refresh = typeof req.query.refresh === 'string'
? ['1', 'true', 'yes'].includes(req.query.refresh.toLowerCase())
: false;
res.json(await service.listConnectorDiscovery({ refresh }));
const hydrateTools = typeof req.query.hydrateTools === 'string'
? ['1', 'true', 'yes'].includes(req.query.hydrateTools.toLowerCase())
: false;
res.json(await service.listConnectorDiscovery({ refresh, hydrateTools }));
} catch (err) {
sendConnectorRouteError(res, err, options.sendApiError);
}
@ -545,12 +548,38 @@ export function registerConnectorRoutes(app: Express, options: RegisterConnector
try {
const connectorId = req.params.connectorId;
if (!connectorId) return options.sendApiError(res, 400, 'CONNECTOR_NOT_FOUND', 'connectorId is required');
const hydrateTools = typeof req.query.hydrateTools === 'string'
? ['1', 'true', 'yes'].includes(req.query.hydrateTools.toLowerCase())
: false;
if (hydrateTools) {
const parsedLimit = typeof req.query.toolsLimit === 'string' ? Number.parseInt(req.query.toolsLimit, 10) : 50;
const toolsLimit = Number.isFinite(parsedLimit) ? Math.min(Math.max(parsedLimit, 1), 1000) : 50;
const toolsCursor = typeof req.query.toolsCursor === 'string' && req.query.toolsCursor.trim().length > 0 ? req.query.toolsCursor : undefined;
res.json({ connector: await service.getPreviewConnector(connectorId, { toolsLimit, ...(toolsCursor === undefined ? {} : { toolsCursor }) }) });
return;
}
res.json({ connector: await service.getConnector(connectorId) });
} catch (err) {
sendConnectorRouteError(res, err, options.sendApiError);
}
});
app.post('/api/connectors/auth-configs/prepare', requireLocalDaemonRequest, async (req: Request, res: Response) => {
try {
const body = isPlainObject(req.body) ? req.body : {};
const connectorIds = Array.isArray(body.connectorIds)
? body.connectorIds.filter((connectorId): connectorId is string => typeof connectorId === 'string')
: [];
if (connectorIds.length === 0) {
options.sendApiError(res, 400, 'VALIDATION_FAILED', 'connectorIds must contain at least one connector id');
return;
}
res.json(await service.prepareAuthConfigs(connectorIds));
} catch (err) {
sendConnectorRouteError(res, err, options.sendApiError);
}
});
app.post('/api/connectors/:connectorId/connect', requireLocalDaemonRequest, async (req: Request, res: Response) => {
try {
const connectorId = req.params.connectorId;
@ -562,7 +591,7 @@ export function registerConnectorRoutes(app: Express, options: RegisterConnector
options.sendApiError(res, 400, 'VALIDATION_FAILED', 'credentials must be an object');
return;
}
const definition = await service.getDefinition(connectorId);
const definition = service.getFastDefinition(connectorId) ?? await service.getDefinition(connectorId);
if (definition?.authentication === 'composio' && credentials !== undefined) {
options.sendApiError(res, 400, 'VALIDATION_FAILED', 'Composio connector credentials can only be stored through OAuth callback completion');
return;
@ -600,6 +629,16 @@ export function registerConnectorRoutes(app: Express, options: RegisterConnector
}
});
app.post('/api/connectors/:connectorId/authorization/cancel', requireLocalDaemonRequest, async (req: Request, res: Response) => {
try {
const connectorId = req.params.connectorId;
if (!connectorId) return options.sendApiError(res, 400, 'CONNECTOR_NOT_FOUND', 'connectorId is required');
res.json({ connector: await service.cancelPendingAuthorization(connectorId) });
} catch (err) {
sendConnectorRouteError(res, err, options.sendApiError);
}
});
app.delete('/api/connectors/:connectorId/connection', requireLocalDaemonRequest, async (req: Request, res: Response) => {
try {
const connectorId = req.params.connectorId;

View file

@ -12,7 +12,7 @@ import {
type ConnectorToolSafety,
type ConnectorStatus,
} from './catalog.js';
import { composioConnectorProvider, getStaticComposioCatalogDefinitions, type ComposioConnectionStart } from './composio.js';
import { composioConnectorProvider, getStaticComposioCatalogDefinitions, type ComposioAuthConfigPrepareResult, type ComposioConnectionStart } from './composio.js';
export interface ConnectorExecuteRequest {
connectorId: string;
@ -37,6 +37,10 @@ export interface ConnectorConnectResult {
auth?: Pick<ComposioConnectionStart, 'kind' | 'redirectUrl' | 'providerConnectionId' | 'expiresAt'>;
}
export interface ConnectorAuthConfigPrepareResponse {
results: Record<string, ComposioAuthConfigPrepareResult>;
}
type PublicComposioConnectionStart = Pick<ComposioConnectionStart, 'kind' | 'redirectUrl' | 'providerConnectionId' | 'expiresAt'>;
function publicComposioAuthStart(auth: ComposioConnectionStart): PublicComposioConnectionStart {
@ -48,6 +52,17 @@ function publicComposioAuthStart(auth: ComposioConnectionStart): PublicComposioC
};
}
function isMissingOrExpiredComposioOAuthState(error: unknown): boolean {
return error instanceof ConnectorServiceError
&& error.code === 'CONNECTOR_EXECUTION_FAILED'
&& error.message === 'Composio OAuth state is missing or expired';
}
function hasStoredComposioConnection(credential: ConnectorCredentialRecord | undefined, providerConnectionId: string): boolean {
return credential?.credentials.provider === 'composio'
&& credential.credentials.providerConnectionId === providerConnectionId;
}
export type ConnectorServiceErrorCode =
| 'CONNECTOR_NOT_FOUND'
| 'CONNECTOR_NOT_CONNECTED'
@ -524,14 +539,31 @@ export class ConnectorService {
return composioConnectorProvider.listDefinitions(signal);
}
async listHydratedDefinitions(signal?: AbortSignal): Promise<ConnectorCatalogDefinition[]> {
return composioConnectorProvider.listDefinitions(signal, { hydrateTools: true });
}
listFastDefinitions(): ConnectorCatalogDefinition[] {
return composioConnectorProvider.getFastDefinitions();
}
getFastDefinition(connectorId: string): ConnectorCatalogDefinition | undefined {
return this.listFastDefinitions().find((definition) => definition.id === connectorId);
}
async getDefinition(connectorId: string, signal?: AbortSignal): Promise<ConnectorCatalogDefinition | undefined> {
return composioConnectorProvider.getDefinition(connectorId, signal);
}
async getHydratedDefinition(connectorId: string, signal?: AbortSignal): Promise<ConnectorCatalogDefinition | undefined> {
return await composioConnectorProvider.getHydratedDefinition(connectorId, signal)
?? await this.getDefinition(connectorId, signal);
}
async getPreviewDefinition(connectorId: string, options: { toolsLimit: number; toolsCursor?: string; signal?: AbortSignal }): Promise<ConnectorCatalogDefinition | undefined> {
return composioConnectorProvider.getPreviewDefinition(connectorId, options);
}
getStatus(definition: ConnectorCatalogDefinition): ConnectorConnectionStatus {
return this.statusService.getStatus(definition);
}
@ -551,12 +583,15 @@ export class ConnectorService {
};
}
async listConnectorDiscovery(options: { refresh?: boolean; signal?: AbortSignal } = {}): Promise<ConnectorDiscoveryResult> {
async listConnectorDiscovery(options: { refresh?: boolean; hydrateTools?: boolean; signal?: AbortSignal } = {}): Promise<ConnectorDiscoveryResult> {
if (options.refresh) composioConnectorProvider.clearDiscoveryCache();
const definitions = options.refresh && !options.hydrateTools
? await composioConnectorProvider.refreshCatalog(options.signal)
: options.hydrateTools
? await this.listHydratedDefinitions(options.signal)
: await this.listDefinitions(options.signal);
return {
connectors: ((options.refresh
? await composioConnectorProvider.refreshCatalog(options.signal)
: await this.listDefinitions(options.signal))).map((definition) => this.toDetail(definition)),
connectors: definitions.map((definition) => this.toDetail(definition)),
meta: {
provider: 'composio',
...(options.refresh ? { refreshRequested: true } : {}),
@ -572,8 +607,44 @@ export class ConnectorService {
return this.toDetail(definition);
}
async getHydratedConnector(connectorId: string, signal?: AbortSignal): Promise<ConnectorDetail> {
const definition = await this.getHydratedDefinition(connectorId, signal);
if (!definition) {
throw new ConnectorServiceError('CONNECTOR_NOT_FOUND', 'connector not found', 404);
}
return this.toDetail(definition);
}
async getPreviewConnector(connectorId: string, options: { toolsLimit: number; toolsCursor?: string; signal?: AbortSignal }): Promise<ConnectorDetail> {
const definition = await this.getPreviewDefinition(connectorId, options);
if (!definition) {
throw new ConnectorServiceError('CONNECTOR_NOT_FOUND', 'connector not found', 404);
}
return this.toDetail(definition);
}
async prepareAuthConfigs(connectorIds: readonly string[], signal?: AbortSignal): Promise<ConnectorAuthConfigPrepareResponse> {
const results: Record<string, ComposioAuthConfigPrepareResult> = {};
const uniqueConnectorIds = [...new Set(connectorIds.map((id) => id.trim()).filter(Boolean))];
await Promise.all(uniqueConnectorIds.map(async (connectorId) => {
const definition = this.getFastDefinition(connectorId) ?? await this.getDefinition(connectorId, signal);
if (!definition) {
results[connectorId] = { status: 'error', message: 'connector not found' };
return;
}
if (definition.authentication !== 'composio') {
results[connectorId] = { status: 'error', message: 'connector is not backed by Composio' };
return;
}
results[connectorId] = await composioConnectorProvider.prepareAuthConfig(definition, signal);
}));
return { results };
}
async connect(connectorId: string, options: { accountLabel?: string; credentials?: ConnectorCredentialMaterial; callbackUrl?: string; signal?: AbortSignal } = {}): Promise<ConnectorConnectResult> {
const definition = await this.getDefinition(connectorId, options.signal);
const definition = this.getFastDefinition(connectorId) ?? await this.getDefinition(connectorId, options.signal);
if (!definition) {
throw new ConnectorServiceError('CONNECTOR_NOT_FOUND', 'connector not found', 404);
}
@ -585,7 +656,6 @@ export class ConnectorService {
throw new ConnectorServiceError('CONNECTOR_EXECUTION_FAILED', 'callbackUrl is required for Composio connectors', 400, { connectorId });
}
auth = await composioConnectorProvider.connect(definition, options.callbackUrl, options.signal);
detailDefinition = await this.getDefinition(connectorId, options.signal) ?? definition;
if (auth.kind === 'redirect_required' || auth.kind === 'pending') {
return { connector: this.toDetail(detailDefinition), auth: publicComposioAuthStart(auth) };
}
@ -602,7 +672,7 @@ export class ConnectorService {
}
async disconnect(connectorId: string): Promise<ConnectorDetail> {
const definition = await this.getDefinition(connectorId);
const definition = this.getFastDefinition(connectorId) ?? await this.getDefinition(connectorId);
if (!definition) {
throw new ConnectorServiceError('CONNECTOR_NOT_FOUND', 'connector not found', 404);
}
@ -613,6 +683,17 @@ export class ConnectorService {
return this.toDetail(definition);
}
async cancelPendingAuthorization(connectorId: string): Promise<ConnectorDetail> {
const definition = this.getFastDefinition(connectorId) ?? await this.getDefinition(connectorId);
if (!definition) {
throw new ConnectorServiceError('CONNECTOR_NOT_FOUND', 'connector not found', 404);
}
if (definition.authentication === 'composio') {
composioConnectorProvider.cancelPendingConnections(connectorId);
}
return this.toDetail(definition);
}
async completeComposioConnection(input: { connectorId: string; state: string; providerConnectionId?: string; status?: string; signal?: AbortSignal }): Promise<ConnectorDetail> {
const definition = await this.getDefinition(input.connectorId, input.signal);
if (!definition) {
@ -621,9 +702,20 @@ export class ConnectorService {
if (definition.authentication !== 'composio') {
throw new ConnectorServiceError('CONNECTOR_EXECUTION_FAILED', 'connector is not backed by Composio', 400, { connectorId: input.connectorId });
}
const completed = await composioConnectorProvider.completeConnection({ definition, state: input.state, ...(input.providerConnectionId === undefined ? {} : { providerConnectionId: input.providerConnectionId }), ...(input.status === undefined ? {} : { status: input.status }), ...(input.signal === undefined ? {} : { signal: input.signal }) });
this.statusService.connect(definition, completed.accountLabel, completed.credentials);
return this.toDetail(definition);
try {
const completed = await composioConnectorProvider.completeConnection({ definition, state: input.state, ...(input.providerConnectionId === undefined ? {} : { providerConnectionId: input.providerConnectionId }), ...(input.status === undefined ? {} : { status: input.status }), ...(input.signal === undefined ? {} : { signal: input.signal }) });
this.statusService.connect(definition, completed.accountLabel, completed.credentials);
return this.toDetail(definition);
} catch (error) {
if (
input.providerConnectionId !== undefined
&& isMissingOrExpiredComposioOAuthState(error)
&& hasStoredComposioConnection(this.getCredential(input.connectorId), input.providerConnectionId)
) {
return this.toDetail(definition);
}
throw error;
}
}
async execute(request: ConnectorExecuteRequest, context: ConnectorExecutionContext): Promise<ConnectorExecuteResponse> {
@ -632,7 +724,7 @@ export class ConnectorService {
candidate.allowedToolNames.includes(request.toolName) &&
candidate.tools.some((tool) => tool.name === request.toolName)
));
const definition = fastDefinition ?? await this.getDefinition(request.connectorId, context.signal);
const definition = fastDefinition ?? await this.getHydratedDefinition(request.connectorId, context.signal);
if (!definition) {
throw new ConnectorServiceError('CONNECTOR_NOT_FOUND', 'connector not found', 404);
}
@ -714,7 +806,7 @@ export class ConnectorService {
resolvedDefinition?: ConnectorCatalogDefinition,
resolvedTool?: ConnectorCatalogToolDefinition,
): Promise<BoundedJsonObject> {
const definition = resolvedDefinition ?? await this.getDefinition(request.connectorId, context.signal);
const definition = resolvedDefinition ?? await this.getHydratedDefinition(request.connectorId, context.signal);
const tool = resolvedTool ?? definition?.tools.find((candidate) => candidate.name === request.toolName);
if (definition?.authentication === 'composio' && tool) {
return composioConnectorProvider.execute(definition, tool, request.input, this.getCredential(request.connectorId)?.credentials, context.signal);

View file

@ -1,4 +1,3 @@
// @ts-nocheck
/**
* Parses GitHub Copilot CLI's `--output-format json` JSONL stream into the
* same UI-friendly events that claude-stream.js emits, so the chat panel
@ -22,10 +21,17 @@
* result -> usage
*/
export function createCopilotStreamHandler(onEvent) {
type StreamEvent = Record<string, unknown>;
type EventSink = (event: StreamEvent) => void;
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}
export function createCopilotStreamHandler(onEvent: EventSink) {
let buffer = '';
function feed(chunk) {
function feed(chunk: string) {
buffer += chunk;
let nl;
while ((nl = buffer.indexOf('\n')) !== -1) {
@ -54,9 +60,9 @@ export function createCopilotStreamHandler(onEvent) {
}
}
function handleObject(obj) {
if (!obj || typeof obj !== 'object' || typeof obj.type !== 'string') return;
const data = obj.data || {};
function handleObject(obj: unknown) {
if (!isRecord(obj) || typeof obj.type !== 'string') return;
const data = isRecord(obj.data) ? obj.data : {};
switch (obj.type) {
case 'session.tools_updated':
@ -109,7 +115,7 @@ export function createCopilotStreamHandler(onEvent) {
usage: obj.usage ?? null,
stopReason:
obj.success === true || obj.exitCode === 0 ? 'completed' : 'error',
durationMs: obj.usage?.sessionDurationMs ?? null,
durationMs: isRecord(obj.usage) ? obj.usage.sessionDurationMs ?? null : null,
});
return;
@ -121,9 +127,10 @@ export function createCopilotStreamHandler(onEvent) {
return { feed, flush };
}
function stringifyResult(r) {
function stringifyResult(r: unknown): string {
if (r == null) return '';
if (typeof r === 'string') return r;
if (!isRecord(r)) return JSON.stringify(r);
if (typeof r.content === 'string') return r.content;
if (typeof r.detailedContent === 'string') return r.detailedContent;
return JSON.stringify(r);

View file

@ -1,4 +1,3 @@
// @ts-nocheck
// Craft references loader. The active skill declares which sections it
// needs via `od.craft.requires`; this module reads the matching files
// from <projectRoot>/craft/<slug>.md and returns a single concatenated
@ -18,13 +17,13 @@ const SLUG_RE = /^[a-z0-9][a-z0-9-]*$/;
* body is the concatenated markdown (each file preceded by a level-3
* section header). sections lists which slugs actually resolved.
*/
export async function loadCraftSections(craftDir, requested) {
export async function loadCraftSections(craftDir: string, requested: unknown[]) {
if (!craftDir || !Array.isArray(requested) || requested.length === 0) {
return { body: "", sections: [] };
}
const seen = new Set();
const parts = [];
const sections = [];
const seen = new Set<string>();
const parts: string[] = [];
const sections: string[] = [];
for (const raw of requested) {
if (typeof raw !== "string") continue;
const slug = raw.trim().toLowerCase();

View file

@ -1,4 +1,3 @@
// @ts-nocheck
// SQLite-backed persistence for projects, conversations, messages, and the
// per-project set of open file tabs. The on-disk project folder under
// .od/projects/<id>/ is still the single owner of the user's actual files
@ -11,10 +10,22 @@ import fs from 'node:fs';
import { randomUUID } from 'node:crypto';
import { migrateCritique } from './critique/persistence.js';
let dbInstance = null;
let dbFile = null;
type SqliteDb = Database.Database;
type DbRow = Record<string, any>;
type JsonObject = Record<string, unknown>;
export function openDatabase(projectRoot, { dataDir } = {}) {
let dbInstance: SqliteDb | null = null;
let dbFile: string | null = null;
function row(value: unknown): DbRow | null {
return value && typeof value === 'object' ? value as DbRow : null;
}
function rows(value: unknown[]): DbRow[] {
return value.map((item) => row(item) ?? {});
}
export function openDatabase(projectRoot: string, { dataDir }: { dataDir?: string } = {}): SqliteDb {
const dir = dataDir ? path.resolve(dataDir) : path.join(projectRoot, '.od');
const file = path.join(dir, 'app.sqlite');
if (dbInstance && dbFile === file) return dbInstance;
@ -36,7 +47,7 @@ export function closeDatabase() {
dbFile = null;
}
function migrate(db) {
function migrate(db: SqliteDb): void {
db.exec(`
CREATE TABLE IF NOT EXISTS projects (
id TEXT PRIMARY KEY,
@ -149,51 +160,51 @@ function migrate(db) {
`);
// Forward-compatible column add for databases created before metadata_json.
// SQLite has no IF NOT EXISTS for ALTER, so we check pragma_table_info.
const cols = db.prepare(`PRAGMA table_info(projects)`).all();
if (!cols.some((c) => c.name === 'metadata_json')) {
const cols = db.prepare(`PRAGMA table_info(projects)`).all() as DbRow[];
if (!cols.some((c: DbRow) => c.name === 'metadata_json')) {
db.exec(`ALTER TABLE projects ADD COLUMN metadata_json TEXT`);
}
const messageCols = db.prepare(`PRAGMA table_info(messages)`).all();
if (!messageCols.some((c) => c.name === 'agent_id')) {
const messageCols = db.prepare(`PRAGMA table_info(messages)`).all() as DbRow[];
if (!messageCols.some((c: DbRow) => c.name === 'agent_id')) {
db.exec(`ALTER TABLE messages ADD COLUMN agent_id TEXT`);
}
if (!messageCols.some((c) => c.name === 'agent_name')) {
if (!messageCols.some((c: DbRow) => c.name === 'agent_name')) {
db.exec(`ALTER TABLE messages ADD COLUMN agent_name TEXT`);
}
if (!messageCols.some((c) => c.name === 'run_id')) {
if (!messageCols.some((c: DbRow) => c.name === 'run_id')) {
db.exec(`ALTER TABLE messages ADD COLUMN run_id TEXT`);
}
if (!messageCols.some((c) => c.name === 'run_status')) {
if (!messageCols.some((c: DbRow) => c.name === 'run_status')) {
db.exec(`ALTER TABLE messages ADD COLUMN run_status TEXT`);
}
if (!messageCols.some((c) => c.name === 'last_run_event_id')) {
if (!messageCols.some((c: DbRow) => c.name === 'last_run_event_id')) {
db.exec(`ALTER TABLE messages ADD COLUMN last_run_event_id TEXT`);
}
if (!messageCols.some((c) => c.name === 'comment_attachments_json')) {
if (!messageCols.some((c: DbRow) => c.name === 'comment_attachments_json')) {
db.exec(`ALTER TABLE messages ADD COLUMN comment_attachments_json TEXT`);
}
const previewCommentCols = db.prepare(`PRAGMA table_info(preview_comments)`).all();
if (!previewCommentCols.some((c) => c.name === 'selection_kind')) {
const previewCommentCols = db.prepare(`PRAGMA table_info(preview_comments)`).all() as DbRow[];
if (!previewCommentCols.some((c: DbRow) => c.name === 'selection_kind')) {
db.exec(`ALTER TABLE preview_comments ADD COLUMN selection_kind TEXT`);
}
if (!previewCommentCols.some((c) => c.name === 'member_count')) {
if (!previewCommentCols.some((c: DbRow) => c.name === 'member_count')) {
db.exec(`ALTER TABLE preview_comments ADD COLUMN member_count INTEGER`);
}
if (!previewCommentCols.some((c) => c.name === 'pod_members_json')) {
if (!previewCommentCols.some((c: DbRow) => c.name === 'pod_members_json')) {
db.exec(`ALTER TABLE preview_comments ADD COLUMN pod_members_json TEXT`);
}
const deploymentCols = db.prepare(`PRAGMA table_info(deployments)`).all();
if (!deploymentCols.some((c) => c.name === 'status')) {
const deploymentCols = db.prepare(`PRAGMA table_info(deployments)`).all() as DbRow[];
if (!deploymentCols.some((c: DbRow) => c.name === 'status')) {
db.exec(`ALTER TABLE deployments ADD COLUMN status TEXT NOT NULL DEFAULT 'ready'`);
}
if (!deploymentCols.some((c) => c.name === 'status_message')) {
if (!deploymentCols.some((c: DbRow) => c.name === 'status_message')) {
db.exec(`ALTER TABLE deployments ADD COLUMN status_message TEXT`);
}
if (!deploymentCols.some((c) => c.name === 'reachable_at')) {
if (!deploymentCols.some((c: DbRow) => c.name === 'reachable_at')) {
db.exec(`ALTER TABLE deployments ADD COLUMN reachable_at INTEGER`);
}
if (!deploymentCols.some((c) => c.name === 'provider_metadata_json')) {
if (!deploymentCols.some((c: DbRow) => c.name === 'provider_metadata_json')) {
db.exec(`ALTER TABLE deployments ADD COLUMN provider_metadata_json TEXT`);
}
migrateCritique(db);
@ -208,41 +219,41 @@ const DEPLOYMENT_COLS = `id, project_id AS projectId, file_name AS fileName,
provider_metadata_json AS providerMetadataJson,
created_at AS createdAt, updated_at AS updatedAt`;
export function listDeployments(db, projectId) {
return db
export function listDeployments(db: SqliteDb, projectId: string) {
return (db
.prepare(
`SELECT ${DEPLOYMENT_COLS}
FROM deployments
WHERE project_id = ?
ORDER BY updated_at DESC`,
)
.all(projectId)
.all(projectId) as DbRow[])
.map(normalizeDeployment);
}
export function getDeployment(db, projectId, fileName, providerId) {
export function getDeployment(db: SqliteDb, projectId: string, fileName: string, providerId: string) {
const row = db
.prepare(
`SELECT ${DEPLOYMENT_COLS}
FROM deployments
WHERE project_id = ? AND file_name = ? AND provider_id = ?`,
)
.get(projectId, fileName, providerId);
.get(projectId, fileName, providerId) as DbRow | undefined;
return row ? normalizeDeployment(row) : null;
}
export function getDeploymentById(db, projectId, id) {
export function getDeploymentById(db: SqliteDb, projectId: string, id: string) {
const row = db
.prepare(
`SELECT ${DEPLOYMENT_COLS}
FROM deployments
WHERE project_id = ? AND id = ?`,
)
.get(projectId, id);
.get(projectId, id) as DbRow | undefined;
return row ? normalizeDeployment(row) : null;
}
export function upsertDeployment(db, deployment) {
export function upsertDeployment(db: SqliteDb, deployment: DbRow) {
const existing = getDeployment(
db,
deployment.projectId,
@ -318,7 +329,7 @@ export function upsertDeployment(db, deployment) {
return getDeployment(db, next.projectId, next.fileName, next.providerId);
}
function normalizeDeployment(row) {
function normalizeDeployment(row: DbRow) {
const providerMetadata = parseJsonOrUndef(row.providerMetadataJson);
const normalizedProviderMetadata =
providerMetadata && typeof providerMetadata === 'object' && !Array.isArray(providerMetadata)
@ -348,7 +359,7 @@ function normalizeDeployment(row) {
};
}
function stringifyJsonObjectOrNull(value) {
function stringifyJsonObjectOrNull(value: unknown) {
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
return Object.keys(value).length > 0 ? JSON.stringify(value) : null;
}
@ -362,18 +373,18 @@ const PROJECT_COLS = `id, name, skill_id AS skillId,
created_at AS createdAt,
updated_at AS updatedAt`;
export function listProjects(db) {
export function listProjects(db: SqliteDb) {
const rows = db
.prepare(
`SELECT ${PROJECT_COLS}
FROM projects
ORDER BY updated_at DESC`,
)
.all();
.all() as DbRow[];
return rows.map(normalizeProject);
}
export function listLatestProjectRunStatuses(db) {
export function listLatestProjectRunStatuses(db: SqliteDb) {
const rows = db
.prepare(
`SELECT c.project_id AS projectId,
@ -385,8 +396,8 @@ export function listLatestProjectRunStatuses(db) {
WHERE m.run_status IS NOT NULL
ORDER BY updatedAt DESC`,
)
.all();
const latestByProject = new Map();
.all() as DbRow[];
const latestByProject = new Map<string, DbRow>();
for (const row of rows) {
if (!latestByProject.has(row.projectId)) {
latestByProject.set(row.projectId, {
@ -399,7 +410,7 @@ export function listLatestProjectRunStatuses(db) {
return latestByProject;
}
export function listProjectsAwaitingInput(db) {
export function listProjectsAwaitingInput(db: SqliteDb) {
const rows = db
.prepare(
`SELECT latest.projectId
@ -429,18 +440,18 @@ export function listProjectsAwaitingInput(db) {
)
)`,
)
.all();
return new Set(rows.map((row) => row.projectId));
.all() as DbRow[];
return new Set((rows as DbRow[]).map((row: DbRow) => row.projectId));
}
export function getProject(db, id) {
export function getProject(db: SqliteDb, id: string) {
const row = db
.prepare(`SELECT ${PROJECT_COLS} FROM projects WHERE id = ?`)
.get(id);
.get(id) as DbRow | undefined;
return row ? normalizeProject(row) : null;
}
export function insertProject(db, p) {
export function insertProject(db: SqliteDb, p: DbRow) {
db.prepare(
`INSERT INTO projects
(id, name, skill_id, design_system_id, pending_prompt,
@ -459,7 +470,7 @@ export function insertProject(db, p) {
return getProject(db, p.id);
}
export function updateProject(db, id, patch) {
export function updateProject(db: SqliteDb, id: string, patch: DbRow) {
const existing = getProject(db, id);
if (!existing) return null;
const merged = {
@ -488,11 +499,11 @@ export function updateProject(db, id, patch) {
return getProject(db, id);
}
export function deleteProject(db, id) {
export function deleteProject(db: SqliteDb, id: string) {
db.prepare(`DELETE FROM projects WHERE id = ?`).run(id);
}
function normalizeProject(row) {
function normalizeProject(row: DbRow) {
let metadata;
if (row.metadataJson) {
try {
@ -513,7 +524,7 @@ function normalizeProject(row) {
};
}
function normalizeProjectRunStatus(status) {
function normalizeProjectRunStatus(status: unknown) {
if (status === 'starting') return 'running';
if (status === 'cancelled') return 'canceled';
if (
@ -530,30 +541,30 @@ function normalizeProjectRunStatus(status) {
// ---------- templates ----------
export function listTemplates(db) {
return db
export function listTemplates(db: SqliteDb) {
return (db
.prepare(
`SELECT id, name, description, source_project_id AS sourceProjectId,
files_json AS filesJson, created_at AS createdAt
FROM templates
ORDER BY created_at DESC`,
)
.all()
.all() as DbRow[])
.map(normalizeTemplate);
}
export function getTemplate(db, id) {
export function getTemplate(db: SqliteDb, id: string) {
const row = db
.prepare(
`SELECT id, name, description, source_project_id AS sourceProjectId,
files_json AS filesJson, created_at AS createdAt
FROM templates WHERE id = ?`,
)
.get(id);
.get(id) as DbRow | undefined;
return row ? normalizeTemplate(row) : null;
}
export function insertTemplate(db, t) {
export function insertTemplate(db: SqliteDb, t: DbRow) {
db.prepare(
`INSERT INTO templates (id, name, description, source_project_id, files_json, created_at)
VALUES (?, ?, ?, ?, ?, ?)`,
@ -568,11 +579,11 @@ export function insertTemplate(db, t) {
return getTemplate(db, t.id);
}
export function deleteTemplate(db, id) {
export function deleteTemplate(db: SqliteDb, id: string) {
db.prepare(`DELETE FROM templates WHERE id = ?`).run(id);
}
function normalizeTemplate(row) {
function normalizeTemplate(row: DbRow) {
let files = [];
try {
files = JSON.parse(row.filesJson || '[]');
@ -591,8 +602,8 @@ function normalizeTemplate(row) {
// ---------- conversations ----------
export function listConversations(db, projectId) {
return db
export function listConversations(db: SqliteDb, projectId: string) {
return (db
.prepare(
`SELECT id, project_id AS projectId, title,
created_at AS createdAt, updated_at AS updatedAt
@ -600,8 +611,8 @@ export function listConversations(db, projectId) {
WHERE project_id = ?
ORDER BY updated_at DESC`,
)
.all(projectId)
.map((r) => ({
.all(projectId) as DbRow[])
.map((r: DbRow) => ({
id: r.id,
projectId: r.projectId,
title: r.title ?? null,
@ -610,14 +621,14 @@ export function listConversations(db, projectId) {
}));
}
export function getConversation(db, id) {
export function getConversation(db: SqliteDb, id: string) {
const r = db
.prepare(
`SELECT id, project_id AS projectId, title,
created_at AS createdAt, updated_at AS updatedAt
FROM conversations WHERE id = ?`,
)
.get(id);
.get(id) as DbRow | undefined;
if (!r) return null;
return {
id: r.id,
@ -628,7 +639,7 @@ export function getConversation(db, id) {
};
}
export function insertConversation(db, c) {
export function insertConversation(db: SqliteDb, c: DbRow) {
db.prepare(
`INSERT INTO conversations
(id, project_id, title, created_at, updated_at)
@ -637,7 +648,7 @@ export function insertConversation(db, c) {
return getConversation(db, c.id);
}
export function updateConversation(db, id, patch) {
export function updateConversation(db: SqliteDb, id: string, patch: DbRow) {
const existing = getConversation(db, id);
if (!existing) return null;
const merged = {
@ -652,14 +663,14 @@ export function updateConversation(db, id, patch) {
return getConversation(db, id);
}
export function deleteConversation(db, id) {
export function deleteConversation(db: SqliteDb, id: string) {
db.prepare(`DELETE FROM conversations WHERE id = ?`).run(id);
}
// ---------- messages ----------
export function listMessages(db, conversationId) {
return db
export function listMessages(db: SqliteDb, conversationId: string) {
return (db
.prepare(
`SELECT id, role, content, agent_id AS agentId, agent_name AS agentName,
run_id AS runId, run_status AS runStatus,
@ -674,14 +685,14 @@ export function listMessages(db, conversationId) {
WHERE conversation_id = ?
ORDER BY position ASC`,
)
.all(conversationId)
.all(conversationId) as DbRow[])
.map(normalizeMessage);
}
export function upsertMessage(db, conversationId, m) {
export function upsertMessage(db: SqliteDb, conversationId: string, m: DbRow) {
const existing = db
.prepare(`SELECT position FROM messages WHERE id = ?`)
.get(m.id);
.get(m.id) as DbRow | undefined;
const now = Date.now();
if (existing) {
db.prepare(
@ -712,7 +723,7 @@ export function upsertMessage(db, conversationId, m) {
.prepare(
`SELECT COALESCE(MAX(position), -1) AS m FROM messages WHERE conversation_id = ?`,
)
.get(conversationId);
.get(conversationId) as DbRow | undefined;
const position = (max?.m ?? -1) + 1;
// 17 values: id, conversation_id, role, content, agent_id, agent_name,
// run_id, run_status, last_run_event_id, events_json, attachments_json,
@ -763,11 +774,11 @@ export function upsertMessage(db, conversationId, m) {
position
FROM messages WHERE id = ?`,
)
.get(m.id);
.get(m.id) as DbRow | undefined;
return row ? normalizeMessage(row) : null;
}
export function deleteMessage(db, id) {
export function deleteMessage(db: SqliteDb, id: string) {
db.prepare(`DELETE FROM messages WHERE id = ?`).run(id);
}
@ -782,8 +793,8 @@ const PREVIEW_COMMENT_STATUSES = new Set([
'failed',
]);
export function listPreviewComments(db, projectId, conversationId) {
return db
export function listPreviewComments(db: SqliteDb, projectId: string, conversationId: string) {
return (db
.prepare(
`SELECT id, project_id AS projectId, conversation_id AS conversationId,
file_path AS filePath, element_id AS elementId, selector, label,
@ -795,11 +806,11 @@ export function listPreviewComments(db, projectId, conversationId) {
WHERE project_id = ? AND conversation_id = ?
ORDER BY updated_at DESC`,
)
.all(projectId, conversationId)
.all(projectId, conversationId) as DbRow[])
.map(normalizePreviewComment);
}
export function upsertPreviewComment(db, projectId, conversationId, input) {
export function upsertPreviewComment(db: SqliteDb, projectId: string, conversationId: string, input: DbRow) {
const target = input?.target ?? {};
const note = typeof input?.note === 'string' ? input.note.trim() : '';
if (!note) throw new Error('comment note required');
@ -826,7 +837,7 @@ export function upsertPreviewComment(db, projectId, conversationId, input) {
FROM preview_comments
WHERE project_id = ? AND conversation_id = ? AND file_path = ? AND element_id = ?`,
)
.get(projectId, conversationId, filePath, elementId);
.get(projectId, conversationId, filePath, elementId) as DbRow | undefined;
const id = existing?.id ?? randomCommentId();
const createdAt = existing?.createdAt ?? now;
db.prepare(
@ -869,7 +880,7 @@ export function upsertPreviewComment(db, projectId, conversationId, input) {
return getPreviewComment(db, projectId, conversationId, id);
}
export function updatePreviewCommentStatus(db, projectId, conversationId, id, status) {
export function updatePreviewCommentStatus(db: SqliteDb, projectId: string, conversationId: string, id: string, status: string) {
if (!PREVIEW_COMMENT_STATUSES.has(status)) throw new Error('invalid comment status');
const now = Date.now();
db.prepare(
@ -880,7 +891,7 @@ export function updatePreviewCommentStatus(db, projectId, conversationId, id, st
return getPreviewComment(db, projectId, conversationId, id);
}
export function deletePreviewComment(db, projectId, conversationId, id) {
export function deletePreviewComment(db: SqliteDb, projectId: string, conversationId: string, id: string) {
const result = db
.prepare(
`DELETE FROM preview_comments
@ -890,7 +901,7 @@ export function deletePreviewComment(db, projectId, conversationId, id) {
return result.changes > 0;
}
function getPreviewComment(db, projectId, conversationId, id) {
function getPreviewComment(db: SqliteDb, projectId: string, conversationId: string, id: string) {
const row = db
.prepare(
`SELECT id, project_id AS projectId, conversation_id AS conversationId,
@ -902,11 +913,11 @@ function getPreviewComment(db, projectId, conversationId, id) {
FROM preview_comments
WHERE id = ? AND project_id = ? AND conversation_id = ?`,
)
.get(id, projectId, conversationId);
.get(id, projectId, conversationId) as DbRow | undefined;
return row ? normalizePreviewComment(row) : null;
}
function normalizePreviewComment(row) {
function normalizePreviewComment(row: DbRow) {
const podMembers = parseJsonOrUndef(row.podMembersJson);
const normalizedPodMembers = Array.isArray(podMembers) ? podMembers : undefined;
return {
@ -935,12 +946,12 @@ function normalizePreviewComment(row) {
};
}
function cleanRequiredString(value, name) {
function cleanRequiredString(value: unknown, name: string): string {
if (typeof value !== 'string' || !value.trim()) throw new Error(`${name} required`);
return value.trim();
}
function normalizePodMembers(input) {
function normalizePodMembers(input: unknown) {
if (!Array.isArray(input)) return [];
return input
.map((member) => {
@ -966,12 +977,12 @@ function normalizePodMembers(input) {
.filter(Boolean);
}
function compactWhitespace(value) {
function compactWhitespace(value: string): string {
return value.replace(/\s+/g, ' ').trim();
}
function normalizePosition(input) {
const value = input && typeof input === 'object' ? input : {};
function normalizePosition(input: unknown) {
const value: DbRow = input && typeof input === 'object' ? input as DbRow : {};
return {
x: finiteNumber(value.x),
y: finiteNumber(value.y),
@ -980,15 +991,15 @@ function normalizePosition(input) {
};
}
function finiteNumber(value) {
return Number.isFinite(value) ? Math.round(value) : 0;
function finiteNumber(value: unknown): number {
return typeof value === 'number' && Number.isFinite(value) ? Math.round(value) : 0;
}
function randomCommentId() {
function randomCommentId(): string {
return `cmt_${randomUUID().slice(0, 8)}`;
}
function normalizeMessage(row) {
function normalizeMessage(row: DbRow) {
return {
id: row.id,
role: row.role,
@ -1008,8 +1019,8 @@ function normalizeMessage(row) {
};
}
function parseJsonOrUndef(s) {
if (!s) return undefined;
function parseJsonOrUndef(s: unknown): any {
if (typeof s !== 'string' || !s) return undefined;
try {
return JSON.parse(s);
} catch {
@ -1019,28 +1030,28 @@ function parseJsonOrUndef(s) {
// ---------- tabs ----------
export function listTabs(db, projectId) {
export function listTabs(db: SqliteDb, projectId: string) {
const rows = db
.prepare(
`SELECT name, position, is_active AS isActive
FROM tabs WHERE project_id = ? ORDER BY position ASC`,
)
.all(projectId);
const active = rows.find((r) => r.isActive) ?? null;
.all(projectId) as DbRow[];
const active = (rows as DbRow[]).find((r: DbRow) => r.isActive) ?? null;
return {
tabs: rows.map((r) => r.name),
tabs: (rows as DbRow[]).map((r: DbRow) => r.name),
active: active ? active.name : null,
};
}
export function setTabs(db, projectId, names, activeName) {
export function setTabs(db: SqliteDb, projectId: string, names: string[], activeName: string | null) {
const tx = db.transaction(() => {
db.prepare(`DELETE FROM tabs WHERE project_id = ?`).run(projectId);
const ins = db.prepare(
`INSERT INTO tabs (project_id, name, position, is_active)
VALUES (?, ?, ?, ?)`,
);
names.forEach((name, i) => {
names.forEach((name: string, i: number) => {
ins.run(projectId, name, i, name === activeName ? 1 : 0);
});
});

File diff suppressed because it is too large Load diff

View file

@ -1,4 +1,3 @@
// @ts-nocheck
/**
* Build a showcase HTML page from a DESIGN.md so the user can see what each
* design system looks like *before* generating anything. We don't try to
@ -11,7 +10,12 @@
* defaults when a token isn't found.
*/
export function renderDesignSystemPreview(id, raw) {
type ColorToken = { name: string; value: string };
type FontHints = { display?: string; heading?: string; body?: string; mono?: string };
type ListTag = 'ul' | 'ol';
type TableAlign = 'left' | 'center' | 'right' | null;
export function renderDesignSystemPreview(id: string, raw: string): string {
const titleMatch = /^#\s+(.+?)\s*$/m.exec(raw);
const title = cleanTitle(titleMatch?.[1] ?? id);
const subtitle = extractSubtitle(raw);
@ -297,7 +301,7 @@ export function renderDesignSystemPreview(id, raw) {
</html>`;
}
function extractSubtitle(raw) {
function extractSubtitle(raw: string): string {
const lines = raw.split(/\r?\n/);
const h1 = lines.findIndex((l) => /^#\s+/.test(l));
if (h1 === -1) return '';
@ -311,11 +315,11 @@ function extractSubtitle(raw) {
return window.split(/\n\n/)[0]?.slice(0, 240) ?? '';
}
function extractColors(raw) {
const colors = [];
const seen = new Set();
function extractColors(raw: string): ColorToken[] {
const colors: ColorToken[] = [];
const seen = new Set<string>();
function push(name, value) {
function push(name: string, value: string): void {
const cleanName = name.replace(/[*_`]+/g, '').replace(/\s+/g, ' ').trim();
if (!cleanName || cleanName.length > 60) return;
const v = normalizeHex(value);
@ -328,25 +332,25 @@ function extractColors(raw) {
// Form A: "- **Background:** `#FAFAFA`" / "- Background: #FAFAFA"
const reA = /^[\s>*-]*\**\s*([A-Za-z][A-Za-z0-9 /&()+_-]{1,40}?)\s*\**\s*[:]\s*`?(#[0-9a-fA-F]{3,8})/gm;
let m;
while ((m = reA.exec(raw)) !== null) push(m[1], m[2]);
while ((m = reA.exec(raw)) !== null) push(m[1] ?? '', m[2] ?? '');
// Form B: "**Stripe Purple** (`#533afd`)" — common in awesome-design-md.
// Token name is whatever's bolded; the hex follows in parens/backticks.
const reB = /\*\*([A-Za-z][A-Za-z0-9 /&()+_-]{1,40}?)\*\*\s*\(?\s*`?(#[0-9a-fA-F]{3,8})/g;
while ((m = reB.exec(raw)) !== null) push(m[1], m[2]);
while ((m = reB.exec(raw)) !== null) push(m[1] ?? '', m[2] ?? '');
return colors;
}
function extractFonts(raw) {
const out = {};
function extractFonts(raw: string): FontHints {
const out: FontHints = {};
// "- **Display / headings:** `'GT Sectra', ...`"
// We want the backticked stack OR the rest of the line.
const re = /^[\s>*-]*\**\s*([A-Za-z][A-Za-z /]{1,30}?)\s*\**\s*[:]\s*`?([^`\n]+?)`?$/gm;
let m;
while ((m = re.exec(raw)) !== null) {
const label = m[1].toLowerCase();
const value = m[2].trim().replace(/[*_`]+$/g, '').trim();
const label = (m[1] ?? '').toLowerCase();
const value = (m[2] ?? '').trim().replace(/[*_`]+$/g, '').trim();
if (!/[a-zA-Z]/.test(value)) continue;
if (value.startsWith('#')) continue;
if (/display|heading|h1|title/.test(label) && !out.display) out.display = value;
@ -356,7 +360,7 @@ function extractFonts(raw) {
return out;
}
function pickColor(colors, hints) {
function pickColor(colors: ColorToken[], hints: string[]): string | null {
for (const hint of hints) {
const needle = hint.toLowerCase();
const found = colors.find((c) => c.name.toLowerCase().includes(needle));
@ -365,7 +369,7 @@ function pickColor(colors, hints) {
return null;
}
function firstNonNeutral(colors) {
function firstNonNeutral(colors: ColorToken[]): string | null {
for (const c of colors) {
const v = c.value.replace('#', '').toLowerCase();
if (v.length !== 6) continue;
@ -380,7 +384,7 @@ function firstNonNeutral(colors) {
return null;
}
function pickReadableForeground(hex) {
function pickReadableForeground(hex: string): string {
const n = normalizeHex(hex);
if (n.length !== 7) return '#ffffff';
const r = parseInt(n.slice(1, 3), 16);
@ -391,7 +395,7 @@ function pickReadableForeground(hex) {
return lum > 0.6 ? '#0a0a0a' : '#ffffff';
}
function normalizeHex(hex) {
function normalizeHex(hex: string): string {
let h = hex.toLowerCase();
if (h.length === 4) {
h = '#' + h.slice(1).split('').map((c) => c + c).join('');
@ -399,11 +403,11 @@ function normalizeHex(hex) {
return h;
}
function cleanTitle(raw) {
function cleanTitle(raw: string): string {
return String(raw).replace(/^Design System (Inspired by|for)\s+/i, '').trim();
}
function escapeHtml(s) {
function escapeHtml(s: string): string {
return String(s).replace(/[&<>"']/g, (c) =>
c === '&' ? '&amp;' : c === '<' ? '&lt;' : c === '>' ? '&gt;' : c === '"' ? '&quot;' : '&#39;',
);
@ -413,10 +417,10 @@ function escapeHtml(s) {
// bullet/ordered lists, blockquotes, fenced code, GFM pipe tables, horizontal
// rules, inline `code` / **bold** / *italic* / [link](url). Not a full markdown
// implementation but covers everything the DESIGN.md files actually use.
function renderMarkdownLite(src) {
function renderMarkdownLite(src: string): string {
const lines = src.split(/\r?\n/);
const out = [];
let inList = null;
const out: string[] = [];
let inList: ListTag | null = null;
let inBlockquote = false;
let inCode = false;
let i = 0;
@ -471,7 +475,7 @@ function renderMarkdownLite(src) {
closeBlockquote();
const headerCells = splitTableRow(line);
const aligns = parseAlignments(lines[i + 1] ?? '', headerCells.length);
const bodyRows = [];
const bodyRows: string[][] = [];
let j = i + 2;
while (j < lines.length) {
const next = (lines[j] ?? '').trimEnd();
@ -489,8 +493,8 @@ function renderMarkdownLite(src) {
if (h) {
closeList();
closeBlockquote();
const level = h[1].length;
out.push(`<h${level}>${inline(h[2])}</h${level}>`);
const level = h[1]?.length ?? 1;
out.push(`<h${level}>${inline(h[2] ?? '')}</h${level}>`);
i++;
continue;
}
@ -524,7 +528,7 @@ function renderMarkdownLite(src) {
out.push('<ul>');
inList = 'ul';
}
out.push(`<li>${inline(li[2])}</li>`);
out.push(`<li>${inline(li[2] ?? '')}</li>`);
i++;
continue;
}
@ -535,7 +539,7 @@ function renderMarkdownLite(src) {
out.push('<ol>');
inList = 'ol';
}
out.push(`<li>${inline(oli[1])}</li>`);
out.push(`<li>${inline(oli[1] ?? '')}</li>`);
i++;
continue;
}
@ -549,30 +553,30 @@ function renderMarkdownLite(src) {
return out.join('\n');
}
function looksLikeTableHeader(line) {
function looksLikeTableHeader(line: string): boolean {
const trimmed = line.trim();
if (!trimmed.includes('|')) return false;
// At least one pipe between non-pipe content.
return /\|/.test(trimmed.replace(/^\||\|$/g, ''));
}
function isTableSeparator(line) {
function isTableSeparator(line: string): boolean {
const trimmed = line.trim();
if (!trimmed.includes('|')) return false;
// Each cell must be only dashes / colons / whitespace.
return splitTableRow(trimmed).every((cell) => /^:?-{1,}:?$/.test(cell.trim()));
}
function splitTableRow(line) {
function splitTableRow(line: string): string[] {
let s = line.trim();
if (s.startsWith('|')) s = s.slice(1);
if (s.endsWith('|')) s = s.slice(0, -1);
return s.split('|').map((c) => c.trim());
}
function parseAlignments(separatorLine, count) {
function parseAlignments(separatorLine: string, count: number): TableAlign[] {
const cells = splitTableRow(separatorLine);
const aligns = [];
const aligns: TableAlign[] = [];
for (let k = 0; k < count; k++) {
const cell = (cells[k] ?? '').trim();
const left = cell.startsWith(':');
@ -584,7 +588,7 @@ function parseAlignments(separatorLine, count) {
return aligns;
}
function renderTable(header, rows, aligns) {
function renderTable(header: string[], rows: string[][], aligns: TableAlign[]): string {
const th = header
.map((cell, k) => {
const align = aligns[k];
@ -607,7 +611,7 @@ function renderTable(header, rows, aligns) {
return `<div class="table-wrap"><table><thead><tr>${th}</tr></thead><tbody>${body}</tbody></table></div>`;
}
function inline(s) {
function inline(s: string): string {
// Process inline tokens. Order matters: code spans first so their content
// isn't further parsed; then bold/italic; then links; finally bare URLs.
const escaped = escapeHtml(s);

View file

@ -1,4 +1,3 @@
// @ts-nocheck
/**
* Build a fully-formed product webpage that demonstrates a design system in
* action not just a list of tokens, but a real-feeling marketing /
@ -10,7 +9,11 @@
* than imported so the two views can evolve independently.
*/
export function renderDesignSystemShowcase(id, raw) {
type ColorToken = { name: string; value: string; role: string };
type FontHints = { display?: string; heading?: string; body?: string; mono?: string };
type RowStatus = 'up' | '';
export function renderDesignSystemShowcase(id: string, raw: string): string {
const titleMatch = /^#\s+(.+?)\s*$/m.exec(raw);
const rawTitle = titleMatch?.[1] ?? id;
const title = cleanTitle(rawTitle);
@ -546,7 +549,7 @@ export function renderDesignSystemShowcase(id, raw) {
</html>`;
}
function featureCard(icon, title, body) {
function featureCard(icon: string, title: string, body: string): string {
return `<div class="feature">
<div class="feature-icon">${escapeHtml(icon)}</div>
<h3>${escapeHtml(title)}</h3>
@ -554,7 +557,7 @@ function featureCard(icon, title, body) {
</div>`;
}
function kpi(label, value, delta) {
function kpi(label: string, value: string, delta: string): string {
return `<div class="kpi">
<div class="label">${escapeHtml(label)}</div>
<div class="value">${escapeHtml(value)}</div>
@ -562,7 +565,7 @@ function kpi(label, value, delta) {
</div>`;
}
function listRow(name, meta, value, status) {
function listRow(name: string, meta: string, value: string, status: RowStatus): string {
const badge = status === 'up' ? '<span class="badge up">↑</span>' : '<span class="badge">·</span>';
return `<div class="list-row">
<div>
@ -574,7 +577,7 @@ function listRow(name, meta, value, status) {
</div>`;
}
function activityRow(name, meta) {
function activityRow(name: string, meta: string): string {
return `<div class="list-row">
<div>
<div class="name">${escapeHtml(name)}</div>
@ -585,7 +588,7 @@ function activityRow(name, meta) {
</div>`;
}
function priceCard(name, price, sub, features, featured) {
function priceCard(name: string, price: string, sub: string, features: string[], featured = false): string {
return `<div class="price-card${featured ? ' featured' : ''}">
<div class="tier-name">${escapeHtml(name)}</div>
<div class="price">${escapeHtml(price)} <small>${escapeHtml(sub)}</small></div>
@ -594,7 +597,7 @@ function priceCard(name, price, sub, features, featured) {
</div>`;
}
function quote(text, name, role) {
function quote(text: string, name: string, role: string): string {
return `<div class="quote">
<p>${escapeHtml(text)}</p>
<div class="quote-author">
@ -607,14 +610,14 @@ function quote(text, name, role) {
</div>`;
}
function faq(q, a) {
function faq(q: string, a: string): string {
return `<div class="faq-item">
<h4>${escapeHtml(q)}</h4>
<p>${escapeHtml(a)}</p>
</div>`;
}
function inlineLineChart() {
function inlineLineChart(): string {
// Deterministic numbers so the chart looks specific (12 weekly data points).
const data = [38, 44, 41, 52, 49, 61, 58, 67, 71, 76, 82, 88];
const max = Math.max(...data);
@ -624,7 +627,7 @@ function inlineLineChart() {
const padX = 8;
const padY = 14;
const stepX = (w - padX * 2) / (data.length - 1);
const norm = (v) => padY + (h - padY * 2) * (1 - (v - min) / (max - min));
const norm = (v: number) => padY + (h - padY * 2) * (1 - (v - min) / (max - min));
const points = data.map((v, i) => `${padX + i * stepX},${norm(v).toFixed(1)}`).join(' ');
const area = `${padX},${h} ${points} ${w - padX},${h}`;
return `<svg viewBox="0 0 ${w} ${h}" preserveAspectRatio="none">
@ -640,7 +643,7 @@ function inlineLineChart() {
</svg>`;
}
function extractSubtitle(raw) {
function extractSubtitle(raw: string): string {
const lines = raw.split(/\r?\n/);
const h1 = lines.findIndex((l) => /^#\s+/.test(l));
if (h1 === -1) return '';
@ -654,10 +657,10 @@ function extractSubtitle(raw) {
return window.split(/\n\n/)[0]?.slice(0, 240) ?? '';
}
export function extractColors(raw) {
const colors = [];
const seen = new Set();
function push(name, value, role) {
export function extractColors(raw: string): ColorToken[] {
const colors: ColorToken[] = [];
const seen = new Set<string>();
function push(name: string, value: string, role: string): void {
const cleanName = String(name).replace(/[*_`]+/g, '').replace(/\s+/g, ' ').trim();
if (!cleanName || cleanName.length > 60) return;
const v = normalizeHex(value);
@ -704,7 +707,7 @@ export function extractColors(raw) {
const after = rest.slice((hex.index ?? 0) + hex[0].length);
const colonIdx = after.search(/[:]/);
const role = colonIdx >= 0 ? after.slice(colonIdx + 1).trim() : '';
push(bold[1], hex[0], role);
push(bold[1] ?? '', hex[0], role);
continue;
}
}
@ -719,20 +722,20 @@ export function extractColors(raw) {
// and "Text" labels.
const spec = /^[\s>*-]*\*{0,2}([A-Za-z][^:*\n]{1,40}?)\*{0,2}\s*[:]\s*\*{0,2}\s*`?(#[0-9a-fA-F]{3,8})/.exec(line);
if (spec) {
push(spec[1], spec[2], spec[1]);
push(spec[1] ?? '', spec[2] ?? '', spec[1] ?? '');
}
}
return colors;
}
function extractFonts(raw) {
const out = {};
function extractFonts(raw: string): FontHints {
const out: FontHints = {};
const re = /^[\s>*-]*\**\s*([A-Za-z][A-Za-z /]{1,30}?)\s*\**\s*[:]\s*`?([^`\n]+?)`?$/gm;
let m;
while ((m = re.exec(raw)) !== null) {
const label = m[1].toLowerCase();
const value = m[2].trim().replace(/[*_`]+$/g, '').trim();
const label = (m[1] ?? '').toLowerCase();
const value = (m[2] ?? '').trim().replace(/[*_`]+$/g, '').trim();
if (!/[a-zA-Z]/.test(value)) continue;
if (value.startsWith('#')) continue;
if (/display|heading|h1|title/.test(label) && !out.display) out.display = value;
@ -742,7 +745,7 @@ function extractFonts(raw) {
return out;
}
function escapeRegex(s) {
function escapeRegex(s: string): string {
return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
@ -750,7 +753,7 @@ function escapeRegex(s) {
// boundaries so descriptive color names like "Cardinal Red" don't satisfy a
// "card" hint, and "Gem Pink" doesn't satisfy "ink" — both real bugs the
// substring-based version produced for the Duolingo and Canva showcases.
function matchesHint(text, hint) {
function matchesHint(text: string, hint: string): boolean {
if (!text) return false;
const needle = hint.toLowerCase().trim();
if (!needle) return false;
@ -758,7 +761,7 @@ function matchesHint(text, hint) {
return re.test(text);
}
function pickColor(colors, hints, exclude = []) {
function pickColor(colors: ColorToken[], hints: string[], exclude: string[] = []): string | null {
// Two-pass lookup: each hint is first checked against every color's role
// description (the prose authors use to explain how the color is used)
// and only then against the bare name. This ensures a `**Snow** … Primary
@ -772,7 +775,7 @@ function pickColor(colors, hints, exclude = []) {
.map((v) => (v == null ? '' : String(v).toLowerCase()))
.filter((v) => v.length > 0),
);
const isAllowed = (c) => !blocked.has(c.value.toLowerCase());
const isAllowed = (c: ColorToken) => !blocked.has(c.value.toLowerCase());
for (const hint of hints) {
const byRole = colors.find((c) => isAllowed(c) && matchesHint(c.role, hint));
if (byRole) return byRole.value;
@ -782,7 +785,7 @@ function pickColor(colors, hints, exclude = []) {
return null;
}
function colorSaturation(hex) {
function colorSaturation(hex: string): number {
const v = String(hex).replace('#', '').toLowerCase();
if (v.length !== 6) return 0;
const r = parseInt(v.slice(0, 2), 16);
@ -793,7 +796,7 @@ function colorSaturation(hex) {
return max === 0 ? 0 : (max - min) / max;
}
function colorLuminance(hex) {
function colorLuminance(hex: string): number {
const v = String(hex).replace('#', '').toLowerCase();
if (v.length !== 6) return 0.5;
const r = parseInt(v.slice(0, 2), 16);
@ -802,7 +805,7 @@ function colorLuminance(hex) {
return (0.299 * r + 0.587 * g + 0.114 * b) / 255;
}
function firstLightish(colors) {
function firstLightish(colors: ColorToken[]): string | null {
for (const c of colors) {
if (colorSaturation(c.value) > 0.15) continue;
if (colorLuminance(c.value) >= 0.92) return c.value;
@ -810,7 +813,7 @@ function firstLightish(colors) {
return null;
}
function firstNonNeutral(colors, exclude = []) {
function firstNonNeutral(colors: ColorToken[], exclude: string[] = []): string | null {
const set = new Set(exclude.map((v) => String(v || '').toLowerCase()));
for (const c of colors) {
if (set.has(c.value.toLowerCase())) continue;
@ -819,7 +822,7 @@ function firstNonNeutral(colors, exclude = []) {
return null;
}
function secondNonNeutral(colors, exclude = []) {
function secondNonNeutral(colors: ColorToken[], exclude: string[] = []): string | null {
const set = new Set(exclude.map((v) => String(v || '').toLowerCase()));
for (const c of colors) {
if (set.has(c.value.toLowerCase())) continue;
@ -828,7 +831,7 @@ function secondNonNeutral(colors, exclude = []) {
return null;
}
function pickReadableForeground(hex) {
function pickReadableForeground(hex: string): string {
const n = normalizeHex(hex);
if (n.length !== 7) return '#ffffff';
const r = parseInt(n.slice(1, 3), 16);
@ -838,7 +841,7 @@ function pickReadableForeground(hex) {
return lum > 0.6 ? '#0a0a0a' : '#ffffff';
}
function mixSurface(bg) {
function mixSurface(bg: string): string {
const n = normalizeHex(bg);
if (n.length !== 7) return '#fafafa';
const r = parseInt(n.slice(1, 3), 16);
@ -847,11 +850,11 @@ function mixSurface(bg) {
const lum = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
// Lift dark backgrounds; tint light backgrounds slightly cooler.
const adjust = lum < 0.4 ? 16 : -8;
const fix = (v) => Math.max(0, Math.min(255, v + adjust)).toString(16).padStart(2, '0');
const fix = (v: number) => Math.max(0, Math.min(255, v + adjust)).toString(16).padStart(2, '0');
return `#${fix(r)}${fix(g)}${fix(b)}`;
}
function normalizeHex(hex) {
function normalizeHex(hex: string): string {
let h = hex.toLowerCase();
if (h.length === 4) {
h = '#' + h.slice(1).split('').map((c) => c + c).join('');
@ -859,15 +862,15 @@ function normalizeHex(hex) {
return h;
}
function cleanTitle(raw) {
function cleanTitle(raw: string): string {
return String(raw).replace(/^Design System (Inspired by|for)\s+/i, '').trim();
}
function oneLine(s) {
function oneLine(s: string): string {
return String(s).replace(/\s+/g, ' ').trim();
}
function escapeHtml(s) {
function escapeHtml(s: string): string {
return String(s).replace(/[&<>"']/g, (c) =>
c === '&' ? '&amp;' : c === '<' ? '&lt;' : c === '>' ? '&gt;' : c === '"' ? '&quot;' : '&#39;',
);

View file

@ -1,4 +1,3 @@
// @ts-nocheck
// Design-system registry. Scans <projectRoot>/design-systems/* for DESIGN.md
// files. Title comes from the first H1. Category comes from a
// `> Category: <name>` blockquote line beneath the H1. Summary is the first
@ -7,8 +6,22 @@
import { readdir, readFile, stat } from 'node:fs/promises';
import path from 'node:path';
export async function listDesignSystems(root) {
const out = [];
export type DesignSystemSurface = 'web' | 'image' | 'video' | 'audio';
export type DesignSystemSummary = {
id: string;
title: string;
category: string;
summary: string;
swatches: string[];
surface: DesignSystemSurface;
body: string;
};
type ColorToken = { name: string; value: string };
export async function listDesignSystems(root: string): Promise<DesignSystemSummary[]> {
const out: DesignSystemSummary[] = [];
let entries = [];
try {
entries = await readdir(root, { withFileTypes: true });
@ -40,7 +53,7 @@ export async function listDesignSystems(root) {
return out;
}
export async function readDesignSystem(root, id) {
export async function readDesignSystem(root: string, id: string): Promise<string | null> {
const file = path.join(root, id, 'DESIGN.md');
try {
return await readFile(file, 'utf8');
@ -49,7 +62,7 @@ export async function readDesignSystem(root, id) {
}
}
function summarize(raw) {
function summarize(raw: string): string {
const lines = raw.split(/\r?\n/);
const firstH1 = lines.findIndex((l) => /^#\s+/.test(l));
if (firstH1 === -1) return '';
@ -64,23 +77,27 @@ function summarize(raw) {
return window.split(/\n\n/)[0]?.slice(0, 240) ?? '';
}
function extractCategory(raw) {
function extractCategory(raw: string): string | undefined {
const m = /^>\s*Category:\s*(.+?)\s*$/im.exec(raw);
return m?.[1];
}
const KNOWN_SURFACES = new Set(['web', 'image', 'video', 'audio']);
function extractSurface(raw) {
const KNOWN_SURFACES = new Set<DesignSystemSurface>(['web', 'image', 'video', 'audio']);
function extractSurface(raw: string): DesignSystemSurface {
const m = /^>\s*Surface:\s*(.+?)\s*$/im.exec(raw);
if (!m) return 'web';
const v = m[1].trim().toLowerCase();
return KNOWN_SURFACES.has(v) ? v : 'web';
const v = m[1]?.trim().toLowerCase();
return isDesignSystemSurface(v) ? v : 'web';
}
function isDesignSystemSurface(value: string | undefined): value is DesignSystemSurface {
return value !== undefined && KNOWN_SURFACES.has(value as DesignSystemSurface);
}
// Strip boilerplate like "Design System Inspired by Cohere" → "Cohere" so
// the picker dropdown reads cleanly. Hand-authored titles that don't match
// the pattern (e.g. "Neutral Modern") pass through unchanged.
function cleanTitle(raw) {
function cleanTitle(raw: string): string {
return raw
.replace(/^Design System (Inspired by|for)\s+/i, '')
.trim();
@ -99,10 +116,10 @@ function cleanTitle(raw) {
* @param {string} raw Markdown body of DESIGN.md
* @returns {string[]} Up to 4 hex strings; [] if extraction fails.
*/
function extractSwatches(raw) {
const colors = [];
const seen = new Set();
function push(name, value) {
function extractSwatches(raw: string): string[] {
const colors: ColorToken[] = [];
const seen = new Set<string>();
function push(name: string, value: string): void {
const cleanName = name.replace(/[*_`]+/g, '').replace(/\s+/g, ' ').trim().toLowerCase();
const v = normalizeHex(value);
if (!v || cleanName.length > 60) return;
@ -117,20 +134,20 @@ function extractSwatches(raw) {
// either position around the closing `**`.
const reA = /^[\s>*-]*\**\s*([A-Za-z][A-Za-z0-9 /&()+_-]{1,40}?)\s*[:]?\s*\**\s*[:]?\s*`?(#[0-9a-fA-F]{3,8})/gm;
let m;
while ((m = reA.exec(raw)) !== null) push(m[1], m[2]);
while ((m = reA.exec(raw)) !== null) push(m[1] ?? '', m[2] ?? '');
// Form B: "**Stripe Purple** (`#533afd`)"
const reB = /\*\*([A-Za-z][A-Za-z0-9 /&()+_-]{1,40}?)\*\*\s*\(?\s*`?(#[0-9a-fA-F]{3,8})/g;
while ((m = reB.exec(raw)) !== null) push(m[1], m[2]);
while ((m = reB.exec(raw)) !== null) push(m[1] ?? '', m[2] ?? '');
if (colors.length === 0) return [];
function pick(hints) {
function pick(hints: string[]): string | null {
for (const h of hints) {
const found = colors.find((c) => c.name.includes(h));
if (found) return found.value;
}
return null;
}
function isNeutral(hex) {
function isNeutral(hex: string): boolean {
if (!/^#[0-9a-f]{6}$/.test(hex)) return false;
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
@ -159,11 +176,11 @@ function extractSwatches(raw) {
return [bg, support, fg, accent];
}
function normalizeHex(raw) {
function normalizeHex(raw: string): string | null {
if (typeof raw !== 'string') return null;
const m = /^#([0-9a-fA-F]{3,8})$/.exec(raw.trim());
if (!m) return null;
let hex = m[1];
let hex = m[1] ?? '';
if (hex.length === 3) hex = hex.split('').map((c) => c + c).join('');
if (hex.length === 4) hex = hex.split('').map((c) => c + c).join('').slice(0, 8);
return '#' + hex.toLowerCase();

View file

@ -1,4 +1,3 @@
// @ts-nocheck
import { execFile } from 'node:child_process';
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
@ -14,17 +13,32 @@ const MAX_XML_ENTRY_BYTES = 5 * 1024 * 1024;
const MAX_PDF_PREVIEW_CONCURRENCY = 2;
const pdfPreviewQueue = createLimiter(MAX_PDF_PREVIEW_CONCURRENCY);
export async function buildDocumentPreview(file) {
type PreviewKind = 'pdf' | 'document' | 'presentation' | 'spreadsheet';
type PreviewSection = { title: string; lines: string[] };
type PreviewFile = { name: string; buffer: Buffer };
type XmlAttrs = Record<string, string>;
type WorkbookSheet = { name: string; path: string };
type ZipEntryWithSize = JSZip.JSZipObject & {
_data?: { uncompressedSize?: number };
};
class PreviewHttpError extends Error {
constructor(message: string, readonly statusCode: number) {
super(message);
this.name = 'PreviewHttpError';
}
}
export async function buildDocumentPreview(file: PreviewFile) {
const kind = kindFor(file.name);
if (!['pdf', 'document', 'presentation', 'spreadsheet'].includes(kind)) {
const err = new Error('unsupported preview type');
err.statusCode = 415;
throw err;
throw new PreviewHttpError('unsupported preview type', 415);
}
const previewKind = kind as PreviewKind;
if (kind === 'pdf') {
if (previewKind === 'pdf') {
return {
kind,
kind: previewKind,
title: path.basename(file.name),
sections: await pdfPreviewQueue(() => previewPdf(file.buffer)),
};
@ -33,28 +47,28 @@ export async function buildDocumentPreview(file) {
assertPreviewInputSize(file.buffer.length);
const zip = await JSZip.loadAsync(file.buffer);
assertZipPreviewSize(zip);
if (kind === 'document') {
if (previewKind === 'document') {
return {
kind,
kind: previewKind,
title: path.basename(file.name),
sections: await previewDocx(zip),
};
}
if (kind === 'presentation') {
if (previewKind === 'presentation') {
return {
kind,
kind: previewKind,
title: path.basename(file.name),
sections: await previewPptx(zip),
};
}
return {
kind,
kind: previewKind,
title: path.basename(file.name),
sections: await previewXlsx(zip),
};
}
async function previewPdf(buffer) {
async function previewPdf(buffer: Buffer): Promise<PreviewSection[]> {
assertPreviewInputSize(buffer.length);
const tmpDir = await mkdtemp(path.join(tmpdir(), 'od-preview-'));
const tmpFile = path.join(tmpDir, 'input.pdf');
@ -86,7 +100,7 @@ async function previewPdf(buffer) {
}
}
async function previewDocx(zip) {
async function previewDocx(zip: JSZip): Promise<PreviewSection[]> {
const xml = await readZipText(zip, 'word/document.xml');
const paragraphs = extractParagraphs(xml, /<w:p\b[\s\S]*?<\/w:p>/g);
return [
@ -97,13 +111,13 @@ async function previewDocx(zip) {
];
}
async function previewPptx(zip) {
async function previewPptx(zip: JSZip): Promise<PreviewSection[]> {
const slideNames = Object.keys(zip.files)
.filter((name) => /^ppt\/slides\/slide\d+\.xml$/i.test(name))
.sort(numericPathSort);
const sections = [];
const sections: PreviewSection[] = [];
for (let i = 0; i < slideNames.length; i += 1) {
const xml = await readZipText(zip, slideNames[i]);
const xml = await readZipText(zip, slideNames[i] ?? '');
const lines = extractTextRuns(xml);
sections.push({
title: `Slide ${i + 1}`,
@ -115,10 +129,10 @@ async function previewPptx(zip) {
: [{ title: 'Presentation', lines: ['No readable slides found.'] }];
}
async function previewXlsx(zip) {
async function previewXlsx(zip: JSZip): Promise<PreviewSection[]> {
const sharedStrings = await readSharedStrings(zip);
const workbook = await readWorkbook(zip);
const sections = [];
const sections: PreviewSection[] = [];
for (const sheet of workbook) {
const xml = await readZipText(zip, sheet.path).catch(() => '');
const lines = extractWorksheetRows(xml, sharedStrings);
@ -132,7 +146,7 @@ async function previewXlsx(zip) {
: [{ title: 'Spreadsheet', lines: ['No readable sheets found.'] }];
}
async function readSharedStrings(zip) {
async function readSharedStrings(zip: JSZip): Promise<string[]> {
const xml = await readZipText(zip, 'xl/sharedStrings.xml').catch(() => '');
if (!xml) return [];
return Array.from(xml.matchAll(/<si\b[\s\S]*?<\/si>/g)).map((m) =>
@ -140,17 +154,17 @@ async function readSharedStrings(zip) {
);
}
async function readWorkbook(zip) {
async function readWorkbook(zip: JSZip): Promise<WorkbookSheet[]> {
const workbookXml = await readZipText(zip, 'xl/workbook.xml').catch(() => '');
const relsXml = await readZipText(zip, 'xl/_rels/workbook.xml.rels').catch(() => '');
const rels = new Map();
const rels = new Map<string, string>();
for (const rel of relsXml.matchAll(/<Relationship\b([^>]*)\/?>/g)) {
const attrs = parseAttrs(rel[1]);
const attrs = parseAttrs(rel[1] ?? '');
if (attrs.Id && attrs.Target) rels.set(attrs.Id, attrs.Target);
}
const sheets = [];
const sheets: WorkbookSheet[] = [];
for (const sheet of workbookXml.matchAll(/<sheet\b([^>]*)\/?>/g)) {
const attrs = parseAttrs(sheet[1]);
const attrs = parseAttrs(sheet[1] ?? '');
const relId = attrs['r:id'];
const target = relId ? rels.get(relId) : null;
if (!target) continue;
@ -166,13 +180,13 @@ async function readWorkbook(zip) {
.map((name, i) => ({ name: `Sheet ${i + 1}`, path: name }));
}
function extractWorksheetRows(xml, sharedStrings) {
const rows = [];
function extractWorksheetRows(xml: string, sharedStrings: string[]): string[] {
const rows: string[] = [];
for (const row of xml.matchAll(/<row\b[\s\S]*?<\/row>/g)) {
const values = [];
const values: string[] = [];
for (const cell of row[0].matchAll(/<c\b([^>]*)>([\s\S]*?)<\/c>/g)) {
const attrs = parseAttrs(cell[1]);
const body = cell[2];
const attrs = parseAttrs(cell[1] ?? '');
const body = cell[2] ?? '';
let value = '';
if (attrs.t === 's') {
const idx = Number(extractFirst(body, /<v>([\s\S]*?)<\/v>/));
@ -189,46 +203,46 @@ function extractWorksheetRows(xml, sharedStrings) {
return rows;
}
function extractParagraphs(xml, paragraphPattern) {
function extractParagraphs(xml: string, paragraphPattern: RegExp): string[] {
return Array.from(xml.matchAll(paragraphPattern))
.map((m) => extractTextRuns(m[0]).join(' ').replace(/\s+/g, ' ').trim())
.filter(Boolean);
}
function extractTextRuns(xml) {
function extractTextRuns(xml: string): string[] {
return Array.from(xml.matchAll(/<a:t[^>]*>([\s\S]*?)<\/a:t>|<w:t[^>]*>([\s\S]*?)<\/w:t>|<t[^>]*>([\s\S]*?)<\/t>/g))
.map((m) => decodeXml(m[1] ?? m[2] ?? m[3] ?? '').trim())
.filter(Boolean);
}
async function readZipText(zip, name) {
async function readZipText(zip: JSZip, name: string): Promise<string> {
const entry = zip.file(name);
if (!entry) throw new Error(`missing ${name}`);
const size = entry._data?.uncompressedSize ?? 0;
const size = (entry as ZipEntryWithSize)._data?.uncompressedSize ?? 0;
if (size > MAX_XML_ENTRY_BYTES) {
const err = new Error('document section too large to preview');
err.statusCode = 413;
throw err;
throw new PreviewHttpError('document section too large to preview', 413);
}
const xml = await entry.async('text');
assertSafeXml(xml);
return xml;
}
function parseAttrs(raw) {
const attrs = {};
function parseAttrs(raw: string): XmlAttrs {
const attrs: XmlAttrs = {};
for (const m of raw.matchAll(/([\w:-]+)="([^"]*)"/g)) {
attrs[m[1]] = decodeXml(m[2]);
const name = m[1];
if (!name) throw new Error('XML attribute match invariant violated');
attrs[name] = decodeXml(m[2] ?? '');
}
return attrs;
}
function extractFirst(raw, pattern) {
function extractFirst(raw: string, pattern: RegExp): string {
const m = raw.match(pattern);
return m ? m[1] ?? '' : '';
}
function decodeXml(raw) {
function decodeXml(raw: unknown): string {
return String(raw)
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
@ -237,41 +251,41 @@ function decodeXml(raw) {
.replace(/&amp;/g, '&');
}
function assertPreviewInputSize(size) {
function assertPreviewInputSize(size: number): void {
if (size > MAX_COMPRESSED_PREVIEW_BYTES) {
const err = new Error('document too large to preview');
err.statusCode = 413;
throw err;
throw new PreviewHttpError('document too large to preview', 413);
}
}
function assertZipPreviewSize(zip) {
function assertZipPreviewSize(zip: JSZip): void {
let total = 0;
for (const entry of Object.values(zip.files)) {
total += entry._data?.uncompressedSize ?? 0;
total += (entry as ZipEntryWithSize)._data?.uncompressedSize ?? 0;
if (total > MAX_UNCOMPRESSED_PREVIEW_BYTES) {
const err = new Error('document too large to preview');
err.statusCode = 413;
throw err;
throw new PreviewHttpError('document too large to preview', 413);
}
}
}
function assertSafeXml(xml) {
function assertSafeXml(xml: string): void {
if (/<!DOCTYPE\b|<!ENTITY\b/i.test(xml)) {
const err = new Error('unsupported XML entities');
err.statusCode = 415;
throw err;
throw new PreviewHttpError('unsupported XML entities', 415);
}
}
function createLimiter(limit) {
function createLimiter<T>(limit: number): (task: () => Promise<T>) => Promise<T> {
let active = 0;
const pending = [];
const pending: Array<{
task: () => Promise<T>;
resolve: (value: T) => void;
reject: (reason?: unknown) => void;
}> = [];
const runNext = () => {
if (active >= limit || pending.length === 0) return;
active += 1;
const { task, resolve, reject } = pending.shift();
const next = pending.shift();
if (!next) throw new Error('preview limiter queue invariant violated');
const { task, resolve, reject } = next;
Promise.resolve()
.then(task)
.then(resolve, reject)
@ -287,7 +301,7 @@ function createLimiter(limit) {
});
}
function numericPathSort(a, b) {
function numericPathSort(a: string, b: string): number {
const an = Number(a.match(/(\d+)(?=\.xml$)/)?.[1] ?? 0);
const bn = Number(b.match(/(\d+)(?=\.xml$)/)?.[1] ?? 0);
return an - bn || a.localeCompare(b);

View file

@ -0,0 +1,680 @@
// One-shot synthesis of a project's design intent into a `DESIGN.md` artifact
// at <projectDir>/DESIGN.md. The endpoint takes the SQLite-backed transcript
// (via `exportProjectTranscript` from PR #493), the project's active design
// system body, and the project's "current artifact" (active artifact tab,
// fallback to newest .artifact.json by manifest.updatedAt, fallback null),
// runs them through Claude's Messages API, and writes the synthesized
// Markdown back to disk atomically.
//
// Per-project lockfile semantics (`.finalize.lock`) mirror PR #493's
// transcript-export hygiene. A second concurrent finalize throws
// `FinalizePackageLockedError`. Stale-lock recovery (e.g. after a crash)
// is out of scope; operators clear via `rm <projectDir>/.finalize.lock`.
//
// API key, base URL, and model flow in via the route's request body
// (matching the proxy at `apps/daemon/src/server.ts`'s
// `/api/proxy/anthropic/stream`). The daemon does NOT store provider
// credentials. `baseUrl` is optional here (intentional divergence from
// the proxy, which requires it) so standard Anthropic users don't need
// to set it; Bedrock / self-hosted-proxy users still can.
//
// Inline `PersistedAgentEvent` shape is restated in this file (the daemon
// tsconfig does not resolve the `@open-design/contracts/api/chat` subpath
// export — verified during PR #493). Schema-mismatch tests in the test
// file would catch any drift between this restated union and the contract.
import { randomBytes } from 'node:crypto';
import fs from 'node:fs';
import * as path from 'node:path';
import Database from 'better-sqlite3';
import type {
FinalizeAnthropicRequest,
FinalizeAnthropicResponse,
FinalizeArtifactRef,
} from '@open-design/contracts/api/finalize';
import { getProject } from './db.js';
import { readDesignSystem } from './design-systems.js';
import {
listFiles,
readProjectFile,
resolveProjectDir,
validateProjectPath,
} from './projects.js';
import { exportProjectTranscript } from './transcript-export.js';
// Re-export the request/response types so existing daemon-internal
// imports (and the route handler) keep their referenced names. The
// canonical definitions live in @open-design/contracts/api/finalize
// per @lefarcen's P2 review feedback on PR #832, with a real runtime
// entrypoint per @mrcfps's review feedback on the same PR.
export type {
FinalizeAnthropicRequest,
FinalizeAnthropicResponse,
FinalizeArtifactRef,
};
const DEFAULT_BASE_URL = 'https://api.anthropic.com';
const DEFAULT_MAX_TOKENS = 16000;
const INPUT_BODY_CAP_BYTES = 384 * 1024;
const LOCK_FILENAME = '.finalize.lock';
const OUTPUT_FILENAME = 'DESIGN.md';
const DEFAULT_TIMEOUT_MS = 120_000;
export interface FinalizeOptions {
apiKey: string;
baseUrl?: string;
model: string;
maxTokens?: number;
now?: () => Date;
fetchImpl?: typeof globalThis.fetch;
signal?: AbortSignal;
}
export class FinalizePackageLockedError extends Error {
constructor(message: string) {
super(message);
this.name = 'FinalizePackageLockedError';
}
}
/**
* Upstream Anthropic call failure with a meaningful HTTP status the route
* handler can map to one of the documented error codes (401/429/502).
*/
export class FinalizeUpstreamError extends Error {
status: number;
rawText: string;
constructor(status: number, rawText: string, message?: string) {
super(message || `upstream Anthropic returned ${status}`);
this.name = 'FinalizeUpstreamError';
this.status = status;
this.rawText = rawText;
}
}
type Db = Database.Database;
interface ResolvedArtifact {
name: string;
body: string;
manifest: { kind?: string; updatedAt?: string; title?: string; entry?: string } | null;
}
/**
* Resolve the project's "current artifact" for the synthesis prompt.
*
* Priority order:
* 1. The file referenced by `tabs.is_active = 1` IF it has an
* `<name>.artifact.json` sidecar present on disk. "Sidecar
* presence" is the discriminator: an inferred manifest (e.g. for
* a bare `.html` file with no sidecar) does NOT count, and an
* active tab pointing at a non-artifact file (`.md`, `.txt`)
* falls through.
* 2. The newest project file with a real `.artifact.json` sidecar,
* sorted by `manifest.updatedAt` descending. Files without an
* `updatedAt` (legacy pre-streaming manifests) sort last.
* 3. `null` no artifact in scope. Caller emits `artifact: null`
* in the response and the prompt's "Current artifact" section
* reads "none".
*
* `metadata` is the project row's `metadata` field (from `getProject`).
* For imported-folder projects, `metadata.baseDir` redirects file IO
* to the user's actual folder; without it, this resolver would only
* look under `.od/projects/<id>` and miss the real artifacts.
*
* Sidecar presence is checked via `existsSync` on the on-disk path so
* the resolver does not depend on `inferLegacyManifest`'s heuristic.
*/
export async function resolveCurrentArtifact(
db: Db,
projectsRoot: string,
projectId: string,
metadata?: { baseDir?: string } | null,
): Promise<ResolvedArtifact | null> {
const dir = resolveProjectDir(projectsRoot, projectId, metadata ?? undefined);
const activeTabRow = db
.prepare(`SELECT name FROM tabs WHERE project_id = ? AND is_active = 1 LIMIT 1`)
.get(projectId) as { name?: unknown } | undefined;
const activeTabName =
activeTabRow && typeof activeTabRow.name === 'string' ? activeTabRow.name : null;
if (activeTabName) {
// Validate the tab name BEFORE composing it into a filesystem path.
// A malformed tab (e.g. `../../../etc/passwd` written by an attacker
// with DB write access) would otherwise probe outside the project
// dir via path.join. validateProjectPath throws on traversal
// segments, absolute paths, null bytes, and reserved segments.
// Invalid tab names fall through to the newest-artifact branch
// rather than aborting finalize. P3 finding from @lefarcen on PR #832.
let safeTabName: string | null = null;
try {
safeTabName = validateProjectPath(activeTabName);
} catch {
safeTabName = null;
}
if (safeTabName) {
const sidecarPath = path.join(dir, `${safeTabName}.artifact.json`);
if (fs.existsSync(sidecarPath)) {
const file = await readProjectFile(
projectsRoot,
projectId,
safeTabName,
metadata ?? undefined,
);
return {
name: file.name,
body: file.buffer.toString('utf8'),
manifest: file.artifactManifest ?? null,
};
}
}
// Active tab points at a non-artifact file (or an unsafe name) — fall
// through to the newest-artifact branch.
}
const files = await listFiles(projectsRoot, projectId, { metadata: metadata ?? undefined });
const candidates = files
.filter((f) => {
// Require a real sidecar on disk; an inferred manifest does not count.
return fs.existsSync(path.join(dir, `${f.name}.artifact.json`));
})
.map((f) => {
const manifest =
f.artifactManifest && typeof f.artifactManifest === 'object'
? f.artifactManifest as { updatedAt?: unknown }
: null;
return {
name: f.name,
updatedAt: typeof manifest?.updatedAt === 'string' ? manifest.updatedAt : '',
};
})
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); // descending; '' sorts last
if (candidates.length > 0) {
const newest = await readProjectFile(
projectsRoot,
projectId,
candidates[0]!.name,
metadata ?? undefined,
);
return {
name: newest.name,
body: newest.buffer.toString('utf8'),
manifest: newest.artifactManifest ?? null,
};
}
return null;
}
export async function finalizeDesignPackage(
db: Db,
projectsRoot: string,
designSystemsRoot: string,
projectId: string,
options: FinalizeOptions,
): Promise<FinalizeAnthropicResponse> {
const project = getProject(db, projectId);
if (!project) {
// Defensive — the route handler validates this and returns 404 before
// reaching here. Kept for direct (non-HTTP) callers, e.g. CLI scripts.
throw new Error(`project not found: ${projectId}`);
}
// Imported-folder projects (created via /api/import/folder) carry
// `metadata.baseDir` and write to the user's actual folder rather than
// `.od/projects/<id>`. resolveProjectDir handles both shapes; calling
// bare `projectDir` would silently land DESIGN.md in the hidden daemon
// data dir for these projects (PR #832 P1 finding from @lefarcen).
const projectMetadata = (project as { metadata?: { baseDir?: string } | null }).metadata ?? null;
const dir = resolveProjectDir(projectsRoot, projectId, projectMetadata ?? undefined);
// For imported-folder projects, `dir` is the user's own directory and
// already exists; mkdirSync is a no-op (recursive:true is idempotent).
// For native projects, it lazily creates `.od/projects/<id>`.
fs.mkdirSync(dir, { recursive: true });
const finalPath = path.join(dir, OUTPUT_FILENAME);
const lockPath = path.join(dir, LOCK_FILENAME);
const tmpPath = path.join(
dir,
`${OUTPUT_FILENAME}.tmp.${process.pid}.${randomBytes(4).toString('hex')}`,
);
const now = options.now ?? (() => new Date());
const baseUrl = options.baseUrl ?? DEFAULT_BASE_URL;
const maxTokens = options.maxTokens ?? DEFAULT_MAX_TOKENS;
let lockFd: number | null = null;
try {
lockFd = fs.openSync(lockPath, 'wx');
} catch (err: unknown) {
if ((err as NodeJS.ErrnoException)?.code === 'EEXIST') {
throw new FinalizePackageLockedError(
`finalize is already in progress for project ${projectId}`,
);
}
throw err;
}
try {
// Phase 3: export transcript via the PR #493 primitive. Returns the
// disk path; we read the body and run it through the truncation
// policy so a 4 MB transcript does not blow Anthropic's context.
const transcriptResult = exportProjectTranscript(db, projectsRoot, projectId, { now });
const transcriptJsonl = fs.readFileSync(transcriptResult.path, 'utf8');
const truncatedJsonl = truncateTranscriptForPrompt(transcriptJsonl);
// Phase 4: design system. Project may not have one selected; readDesignSystem
// returns null on missing DESIGN.md so the prompt's design-system section
// gracefully falls back to "(no design system selected for this project)".
const designSystemId =
typeof (project as { designSystemId?: unknown }).designSystemId === 'string'
? ((project as { designSystemId: string }).designSystemId)
: null;
const designSystemBody = designSystemId
? await readDesignSystem(designSystemsRoot, designSystemId)
: null;
// Phase 5: current artifact (active tab → newest .artifact.json → null).
// Thread metadata so imported-folder projects discover the real artifacts
// under metadata.baseDir rather than the empty `.od/projects/<id>` dir.
const artifact = await resolveCurrentArtifact(
db,
projectsRoot,
projectId,
projectMetadata,
);
// Phase 6: build prompt.
const { systemPrompt, userPrompt } = buildSynthesisPrompt({
projectId,
transcriptJsonl: truncatedJsonl,
transcriptMessageCount: transcriptResult.messageCount,
designSystemId,
designSystemBody,
artifact,
now: now(),
});
// Phase 7: Anthropic call with bounded blocking timeout. We use our own
// AbortController if the caller did not pass one; either way the call
// bounds at DEFAULT_TIMEOUT_MS.
//
// Network errors (DNS, ECONNREFUSED, ECONNRESET) and JSON parse errors
// on the response body are rewrapped as FinalizeUpstreamError(502) so
// the route handler maps them to 502 UPSTREAM_FAILED rather than 500
// INTERNAL. Per @lefarcen P1 review on PR #832: only HTTP-non-OK
// responses were previously wrapped, leaving DNS/parse failures to
// surface as generic 500s.
const ownController = options.signal ? null : new AbortController();
const timeoutId = ownController
? setTimeout(() => ownController.abort(), DEFAULT_TIMEOUT_MS)
: null;
let response: Response;
try {
const callParams: AnthropicCallParams = {
apiKey: options.apiKey,
baseUrl,
model: options.model,
maxTokens,
systemPrompt,
userPrompt,
};
const signalToUse = options.signal ?? ownController?.signal;
if (signalToUse) callParams.signal = signalToUse;
if (options.fetchImpl) callParams.fetchImpl = options.fetchImpl;
try {
response = await callAnthropicWithRetry(callParams);
} catch (err: unknown) {
if (err instanceof FinalizeUpstreamError) throw err;
const errName =
err && typeof err === 'object' && 'name' in err
? (err as { name?: unknown }).name
: '';
if (errName === 'AbortError') throw err; // route handler maps to 503
// Network-level failure (TypeError from fetch, ENOTFOUND/ECONNREFUSED
// via cause.code, etc.) — rewrap as upstream failure so the route
// handler maps to 502 UPSTREAM_FAILED with redacted details.
const message = err instanceof Error ? err.message : String(err);
throw new FinalizeUpstreamError(502, '', `upstream network error: ${message}`);
}
} finally {
if (timeoutId !== null) clearTimeout(timeoutId);
}
// Phase 8: extract DESIGN.md body and usage counters. A 200 with a body
// that isn't valid JSON (or isn't an object) is treated as an upstream
// failure rather than letting JSON.parse's SyntaxError surface as 500.
let payload: unknown;
try {
payload = await response.json();
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
throw new FinalizeUpstreamError(
502,
'',
`upstream Anthropic returned non-JSON body: ${message}`,
);
}
const designMd = extractDesignMd(payload);
const usage = (payload as { usage?: { input_tokens?: number; output_tokens?: number } }).usage ?? {};
const inputTokens = typeof usage.input_tokens === 'number' ? usage.input_tokens : 0;
const outputTokens = typeof usage.output_tokens === 'number' ? usage.output_tokens : 0;
// Phase 9: atomic write. Mirror PR #493: writeFileSync({flag:'wx'}) →
// reopen for fsync → rename. On any failure unlink tmp; rethrow so the
// route handler maps the error.
const encoded = Buffer.from(designMd, 'utf8');
try {
fs.writeFileSync(tmpPath, encoded, { flag: 'wx' });
const fsyncFd = fs.openSync(tmpPath, 'r+');
try {
fs.fsyncSync(fsyncFd);
} finally {
fs.closeSync(fsyncFd);
}
fs.renameSync(tmpPath, finalPath);
} catch (err) {
try {
fs.unlinkSync(tmpPath);
} catch {
// tmp may not exist if writeFileSync threw before creating it
}
throw err;
}
return {
designMdPath: finalPath,
bytesWritten: encoded.length,
model: options.model,
inputTokens,
outputTokens,
artifact: artifact
? {
name: artifact.name,
updatedAt:
artifact.manifest && typeof artifact.manifest.updatedAt === 'string'
? artifact.manifest.updatedAt
: null,
}
: null,
transcriptMessageCount: transcriptResult.messageCount,
designSystemId,
};
} finally {
if (lockFd !== null) {
try {
fs.closeSync(lockFd);
} catch {
// ignore close-after-error
}
try {
fs.unlinkSync(lockPath);
} catch {
// lock may already be gone if disk vanished; not fatal
}
}
}
}
/**
* Append `/v1/<suffix>` to a base URL, but only if the URL does not
* already include a `/vN` segment. Mirrors the helper inlined in
* `apps/daemon/src/connectionTest.ts:188-195` (not exported there).
*/
export function appendVersionedApiPath(baseUrl: string, suffix: string): string {
const url = new URL(baseUrl);
const pathname = url.pathname.replace(/\/+$/, '');
url.pathname = /\/v\d+(\/|$)/.test(pathname) ? `${pathname}${suffix}` : `${pathname}/v1${suffix}`;
return url.toString();
}
export interface AnthropicCallParams {
apiKey: string;
baseUrl: string;
model: string;
maxTokens: number;
systemPrompt: string;
userPrompt: string;
signal?: AbortSignal;
fetchImpl?: typeof globalThis.fetch;
/** Test-only: skip the inter-attempt sleep so retries are instant. */
_sleepMs?: (ms: number) => Promise<void>;
}
const defaultSleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
/**
* Call Anthropic's Messages API once, retrying once on a transient
* upstream failure (HTTP 429 or 5xx). On a terminal failure, throw a
* `FinalizeUpstreamError` carrying the upstream HTTP status and raw
* body text the route handler maps the status to one of
* AUTH_FAILED / RATE_LIMITED / UPSTREAM_FAILED and runs the raw body
* through `redactSecrets` before exposing it as `details` on the
* error JSON.
*
* Retry posture (1 retry) 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).
*/
export async function callAnthropicWithRetry(
params: AnthropicCallParams,
): Promise<Response> {
const fetchImpl = params.fetchImpl ?? globalThis.fetch;
const sleep = params._sleepMs ?? defaultSleep;
const url = appendVersionedApiPath(params.baseUrl, '/messages');
const headers: Record<string, string> = {
'content-type': 'application/json',
'x-api-key': params.apiKey,
'anthropic-version': '2023-06-01',
};
const body = JSON.stringify({
model: params.model,
max_tokens: params.maxTokens,
system: params.systemPrompt,
messages: [{ role: 'user', content: params.userPrompt }],
stream: false,
});
for (let attempt = 0; attempt <= 1; attempt += 1) {
const init: RequestInit = { method: 'POST', headers, body };
if (params.signal) init.signal = params.signal;
const response = await fetchImpl(url, init);
if (response.ok) return response;
const transient = response.status === 429 || response.status >= 500;
if (!transient || attempt === 1) {
const text = await response.text().catch(() => '');
throw new FinalizeUpstreamError(response.status, text);
}
// Linear backoff: 1s on attempt 0. Two retries would extend to 2s on
// attempt 1 — kept at one retry to stay within the daemon's blocking-
// fast posture for `/finalize`.
await sleep(1000 * (attempt + 1));
}
// Loop above always returns or throws within two iterations. This is
// unreachable; satisfies TypeScript control-flow analysis.
throw new Error('callAnthropicWithRetry: unreachable');
}
/**
* Extract the Markdown body from Anthropic's Messages API response.
* Concatenates `content[].text` for every block where `type === 'text'`,
* preserving order. Throws `FinalizeUpstreamError(502)` if the response
* shape is unexpected (no content array, no text blocks) synthesis
* cannot proceed, and the route handler maps the throw to
* `502 UPSTREAM_FAILED` rather than producing an empty DESIGN.md on disk.
*/
export function extractDesignMd(payload: unknown): string {
if (!payload || typeof payload !== 'object') {
throw new FinalizeUpstreamError(502, '', 'upstream Anthropic response was not an object');
}
const content = (payload as { content?: unknown }).content;
if (!Array.isArray(content)) {
throw new FinalizeUpstreamError(
502,
'',
'upstream Anthropic response had no content array',
);
}
let out = '';
for (const block of content) {
if (!block || typeof block !== 'object') continue;
const b = block as { type?: unknown; text?: unknown };
if (b.type === 'text' && typeof b.text === 'string') out += b.text;
}
if (out.length === 0) {
throw new FinalizeUpstreamError(
502,
'',
'upstream Anthropic response contained no text blocks',
);
}
return out;
}
const SYSTEM_PROMPT = `You are a senior product designer synthesizing a finalized design package
from a multi-turn design session. Your output is a single Markdown document
named DESIGN.md that captures the durable design intent of the work so a
fresh contributor (human or LLM) can reconstruct context without replaying
the full chat.
Output structure (Markdown headings exactly as below):
# DESIGN.md
## Summary
## Brand & Voice
## Information Architecture
## Components & Patterns
## Visual System
## Open Questions
## Provenance
The Provenance section MUST list:
- Project ID
- Design system (or "none" if not selected)
- Current artifact (file name, or "none" if not in scope)
- Transcript message count
- Generated UTC timestamp
Output the Markdown body only. No preamble, no chat-style framing, no
"Here's your DESIGN.md" prefix. Do not invent facts not supported by the
inputs; if an input is missing or empty, the corresponding section should
say so explicitly rather than fabricating content.`;
export interface SynthesisPromptInput {
projectId: string;
transcriptJsonl: string;
transcriptMessageCount: number;
designSystemId: string | null;
designSystemBody: string | null;
artifact: ResolvedArtifact | null;
now: Date;
}
export interface SynthesisPromptOutput {
systemPrompt: string;
userPrompt: string;
}
/**
* Build the system + user prompts for the Anthropic Messages API call.
* Inputs are verbatim except for the transcript (which the caller has
* already passed through `truncateTranscriptForPrompt` this function
* does not re-truncate). Missing inputs (no design system selected, no
* artifact in scope) produce explicit "none"/parenthetical placeholders
* so Claude does not hallucinate content for absent sections.
*/
export function buildSynthesisPrompt(input: SynthesisPromptInput): SynthesisPromptOutput {
const designSystemHeader = input.designSystemId ?? 'none';
const designSystemBody =
input.designSystemBody && input.designSystemBody.trim().length > 0
? input.designSystemBody
: '(no design system selected for this project)';
const artifactHeader = input.artifact ? input.artifact.name : 'none';
const artifactBody = input.artifact
? input.artifact.body
: '(no artifact in scope for this finalize)';
const userPrompt =
`The following inputs describe the design session for project ${input.projectId}.\n\n` +
`## Transcript (JSONL)\n${input.transcriptJsonl}\n\n` +
`## Active design system: ${designSystemHeader}\n${designSystemBody}\n\n` +
`## Current artifact: ${artifactHeader}\n${artifactBody}\n\n` +
`## Generation context\n` +
`- Generated at: ${input.now.toISOString()}\n` +
`- Project ID: ${input.projectId}\n` +
`- Transcript message count: ${input.transcriptMessageCount}\n\n` +
`Synthesize DESIGN.md per the system instructions.`;
return { systemPrompt: SYSTEM_PROMPT, userPrompt };
}
/**
* Truncate a JSONL transcript body so it fits inside Claude's context
* window when fed into a synthesis prompt. The on-disk transcript stays
* untouched (PR #493's lossless contract); this function operates on a
* copy that lives only in the prompt.
*
* Strategy: keep the header line (line 0); if the remaining body exceeds
* INPUT_BODY_CAP_BYTES (minus the header + marker reservation), retain
* head and tail lines in roughly equal byte budgets and drop the middle
* with a single sentinel JSON line:
*
* {"kind":"truncated","reason":"size","omittedBytes":<N>}
*
* `omittedBytes` is the difference between the original UTF-8 byte
* length and the truncated output's UTF-8 byte length, so a synthesis
* consumer can detect the gap.
*
* If 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.
*/
export function truncateTranscriptForPrompt(jsonl: string): string {
const buf = Buffer.from(jsonl, 'utf8');
if (buf.byteLength <= INPUT_BODY_CAP_BYTES) return jsonl;
const lines = jsonl.split('\n');
const header = lines[0] ?? '';
const body = lines.slice(1);
const markerLine = '{"kind":"truncated","reason":"size","omittedBytes":__N__}';
const reservedBytes =
Buffer.byteLength(header + '\n', 'utf8') +
Buffer.byteLength(markerLine + '\n', 'utf8') +
64;
const perSideBudget = Math.floor((INPUT_BODY_CAP_BYTES - reservedBytes) / 2);
const headLines: string[] = [];
let headBytes = 0;
let headIndex = 0;
for (; headIndex < body.length; headIndex += 1) {
const line = body[headIndex] ?? '';
const lineBytes = Buffer.byteLength(line + '\n', 'utf8');
if (headBytes + lineBytes > perSideBudget) break;
headLines.push(line);
headBytes += lineBytes;
}
const tailLines: string[] = [];
let tailBytes = 0;
for (let i = body.length - 1; i >= headIndex; i -= 1) {
const line = body[i] ?? '';
const lineBytes = Buffer.byteLength(line + '\n', 'utf8');
if (tailBytes + lineBytes > perSideBudget) break;
tailLines.unshift(line);
tailBytes += lineBytes;
}
if (headLines.length + tailLines.length >= body.length) {
// Head + tail covers the whole body — no truncation needed beyond the
// marker reservation. Return verbatim.
return [header, ...headLines, ...tailLines].join('\n');
}
const without = [header, ...headLines, ...tailLines].join('\n');
const omittedBytes = buf.byteLength - Buffer.byteLength(without, 'utf8');
const marker = markerLine.replace('__N__', String(omittedBytes));
return [header, ...headLines, marker, ...tailLines].join('\n');
}

View file

@ -1,35 +1,47 @@
// @ts-nocheck
// Minimal YAML front-matter parser. Handles the subset used by SKILL.md in
// our examples: scalar strings/numbers/booleans, block-literal (|) strings,
// and flat arrays ("- foo"). Keeps the daemon dep-free. If you need real
// YAML (nested objects, flow-style, anchors), swap for `yaml` or `js-yaml`.
export function parseFrontmatter(src) {
type FrontmatterScalar = string | number | boolean | null;
type FrontmatterValue = FrontmatterScalar | FrontmatterArray | FrontmatterObject;
interface FrontmatterArray extends Array<FrontmatterValue> {}
interface FrontmatterObject extends Record<string, FrontmatterValue> {}
type FrontmatterContainer = FrontmatterObject | FrontmatterArray;
type StackEntry = {
indent: number;
container: FrontmatterContainer;
key: string | null;
};
export function parseFrontmatter(src: string): { data: FrontmatterObject; body: string } {
const text = src.replace(/^/, '');
const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/.exec(text);
if (!match) return { data: {}, body: text };
const [, yaml, body] = match;
const yaml = match[1] ?? '';
const body = match[2] ?? '';
return { data: parseYamlSubset(yaml), body };
}
function parseYamlSubset(src) {
function parseYamlSubset(src: string): FrontmatterObject {
const lines = src.split(/\r?\n/);
const root = {};
const stack = [{ indent: -1, container: root, key: null }];
const root: FrontmatterObject = {};
const stack: StackEntry[] = [{ indent: -1, container: root, key: null }];
let i = 0;
while (i < lines.length) {
const raw = lines[i];
const raw = lines[i] ?? '';
if (/^\s*(#.*)?$/.test(raw)) {
i++;
continue;
}
const indent = raw.match(/^\s*/)[0].length;
const indent = raw.match(/^\s*/)?.[0].length ?? 0;
while (stack.length > 1 && indent <= stack[stack.length - 1].indent) {
while (stack.length > 1 && indent <= (stack[stack.length - 1]?.indent ?? -1)) {
stack.pop();
}
const top = stack[stack.length - 1];
if (!top) throw new Error('frontmatter parser stack invariant violated');
const line = raw.slice(indent);
// Array item
@ -40,8 +52,11 @@ function parseYamlSubset(src) {
// Convert the pending key's value to an array on first `-`.
const parent = stack[stack.length - 2];
if (parent && top.key) {
if (Array.isArray(parent.container)) {
throw new Error('invalid frontmatter array nesting');
}
parent.container[top.key] = [];
container = parent.container[top.key];
container = parent.container[top.key] as FrontmatterArray;
top.container = container;
} else {
i++;
@ -49,14 +64,16 @@ function parseYamlSubset(src) {
}
}
if (value.includes(':')) {
const obj = {};
const obj: FrontmatterObject = {};
const colonIdx = value.indexOf(':');
const key = value.slice(0, colonIdx).trim();
const valRaw = value.slice(colonIdx + 1).trim();
if (valRaw) obj[key] = coerce(valRaw);
if (!Array.isArray(container)) throw new Error('frontmatter array container expected');
container.push(obj);
stack.push({ indent, container: obj, key: null });
} else {
if (!Array.isArray(container)) throw new Error('frontmatter array container expected');
container.push(coerce(value));
}
i++;
@ -69,10 +86,11 @@ function parseYamlSubset(src) {
i++;
continue;
}
const key = kv[1].trim();
const key = (kv[1] ?? '').trim();
const val = kv[2];
if (val === '' || val === undefined) {
if (Array.isArray(top.container)) throw new Error('frontmatter object container expected');
top.container[key] = {};
stack.push({ indent, container: top.container[key], key });
i++;
@ -84,28 +102,31 @@ function parseYamlSubset(src) {
const childIndent = indent + 2;
i++;
while (i < lines.length) {
const next = lines[i];
const next = lines[i] ?? '';
if (/^\s*$/.test(next)) {
collected.push('');
i++;
continue;
}
const nIndent = next.match(/^\s*/)[0].length;
const nIndent = next.match(/^\s*/)?.[0].length ?? 0;
if (nIndent < childIndent) break;
collected.push(next.slice(childIndent));
i++;
}
if (Array.isArray(top.container)) throw new Error('frontmatter object container expected');
top.container[key] = collected.join('\n').trimEnd();
continue;
}
if (val === '[]') {
if (Array.isArray(top.container)) throw new Error('frontmatter object container expected');
top.container[key] = [];
i++;
continue;
}
if (val.startsWith('[') && val.endsWith(']')) {
if (Array.isArray(top.container)) throw new Error('frontmatter object container expected');
top.container[key] = val
.slice(1, -1)
.split(',')
@ -115,6 +136,7 @@ function parseYamlSubset(src) {
continue;
}
if (Array.isArray(top.container)) throw new Error('frontmatter object container expected');
top.container[key] = coerce(val);
i++;
}
@ -122,7 +144,7 @@ function parseYamlSubset(src) {
return root;
}
function coerce(raw) {
function coerce(raw: string | undefined): FrontmatterValue {
if (raw === undefined) return '';
let v = raw.trim();
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) {

View file

@ -1,5 +1,28 @@
// @ts-nocheck
function safeParseJson(value) {
type JsonObject = Record<string, unknown>;
type StreamEvent = Record<string, unknown>;
type StreamEventHandler = (event: StreamEvent) => void;
type ParserKind = string;
type ParserState = {
cursorTextSoFar: string;
openCodeToolUses: Set<string>;
codexToolUses: Set<string>;
codexErrorEmitted: boolean;
};
type Usage = {
input_tokens?: number;
output_tokens?: number;
thought_tokens?: number;
cached_read_tokens?: number;
cached_write_tokens?: number;
};
function isRecord(value: unknown): value is JsonObject {
return value != null && typeof value === 'object' && !Array.isArray(value);
}
function safeParseJson(value: unknown): unknown {
if (value == null) return null;
if (typeof value === 'object') return value;
if (typeof value !== 'string') return null;
@ -10,7 +33,7 @@ function safeParseJson(value) {
}
}
function stringifyContent(value) {
function stringifyContent(value: unknown): string {
if (typeof value === 'string') return value;
if (value == null) return '';
try {
@ -20,7 +43,7 @@ function stringifyContent(value) {
}
}
function extractErrorMessage(value, fallback) {
function extractErrorMessage(value: unknown, fallback: string): string {
if (typeof value === 'string') {
const parsed = safeParseJson(value);
if (parsed && typeof parsed === 'object') {
@ -28,7 +51,7 @@ function extractErrorMessage(value, fallback) {
}
return value;
}
if (value && typeof value === 'object') {
if (isRecord(value)) {
if (typeof value.detail === 'string' && value.detail) return value.detail;
if (typeof value.message === 'string' && value.message) {
return extractErrorMessage(value.message, value.message);
@ -46,22 +69,22 @@ function extractErrorMessage(value, fallback) {
return fallback;
}
function formatOpenCodeUsage(tokens) {
if (!tokens || typeof tokens !== 'object') return null;
const usage = {};
function formatOpenCodeUsage(tokens: unknown): Usage | null {
if (!isRecord(tokens)) return null;
const usage: Usage = {};
if (typeof tokens.input === 'number') usage.input_tokens = tokens.input;
if (typeof tokens.output === 'number') usage.output_tokens = tokens.output;
if (typeof tokens.reasoning === 'number') usage.thought_tokens = tokens.reasoning;
if (tokens.cache && typeof tokens.cache === 'object') {
if (isRecord(tokens.cache)) {
if (typeof tokens.cache.read === 'number') usage.cached_read_tokens = tokens.cache.read;
if (typeof tokens.cache.write === 'number') usage.cached_write_tokens = tokens.cache.write;
}
return Object.keys(usage).length > 0 ? usage : null;
}
function handleOpenCodeEvent(obj, onEvent, state) {
if (!obj || typeof obj !== 'object') return false;
const part = obj.part && typeof obj.part === 'object' ? obj.part : {};
function handleOpenCodeEvent(obj: unknown, onEvent: StreamEventHandler, state: ParserState): boolean {
if (!isRecord(obj)) return false;
const part = isRecord(obj.part) ? obj.part : {};
if (obj.type === 'step_start') {
onEvent({ type: 'status', label: 'running' });
@ -74,7 +97,7 @@ function handleOpenCodeEvent(obj, onEvent, state) {
}
if (obj.type === 'tool_use' && typeof part.tool === 'string' && typeof part.callID === 'string') {
const statePart = part.state && typeof part.state === 'object' ? part.state : null;
const statePart = isRecord(part.state) ? part.state : null;
const key = `${obj.sessionID || 'session'}:${part.callID}`;
if (!state.openCodeToolUses.has(key)) {
state.openCodeToolUses.add(key);
@ -131,8 +154,8 @@ function handleOpenCodeEvent(obj, onEvent, state) {
return false;
}
function handleGeminiEvent(obj, onEvent) {
if (!obj || typeof obj !== 'object') return false;
function handleGeminiEvent(obj: unknown, onEvent: StreamEventHandler): boolean {
if (!isRecord(obj)) return false;
if (obj.type === 'init') {
onEvent({
@ -153,8 +176,8 @@ function handleGeminiEvent(obj, onEvent) {
return true;
}
if (obj.type === 'result' && obj.stats && typeof obj.stats === 'object') {
const usage = {};
if (obj.type === 'result' && isRecord(obj.stats)) {
const usage: Usage = {};
if (typeof obj.stats.input_tokens === 'number') usage.input_tokens = obj.stats.input_tokens;
if (typeof obj.stats.output_tokens === 'number') usage.output_tokens = obj.stats.output_tokens;
if (typeof obj.stats.cached === 'number') usage.cached_read_tokens = obj.stats.cached;
@ -169,15 +192,16 @@ function handleGeminiEvent(obj, onEvent) {
return false;
}
function extractCursorText(message) {
const blocks = Array.isArray(message?.content) ? message.content : [];
function extractCursorText(message: unknown): string {
const content = isRecord(message) ? message.content : undefined;
const blocks = Array.isArray(content) ? content : [];
return blocks
.filter((block) => block && block.type === 'text' && typeof block.text === 'string')
.filter((block): block is { type: 'text'; text: string } => isRecord(block) && block.type === 'text' && typeof block.text === 'string')
.map((block) => block.text)
.join('');
}
function emitCursorTextDelta(text, onEvent, state) {
function emitCursorTextDelta(text: string, onEvent: StreamEventHandler, state: ParserState): void {
if (!state.cursorTextSoFar) {
state.cursorTextSoFar = text;
onEvent({ type: 'text_delta', delta: text });
@ -196,8 +220,8 @@ function emitCursorTextDelta(text, onEvent, state) {
onEvent({ type: 'text_delta', delta: text });
}
function handleCursorEvent(obj, onEvent, state) {
if (!obj || typeof obj !== 'object') return false;
function handleCursorEvent(obj: unknown, onEvent: StreamEventHandler, state: ParserState): boolean {
if (!isRecord(obj)) return false;
if (obj.type === 'system' && obj.subtype === 'init') {
onEvent({
@ -219,8 +243,8 @@ function handleCursorEvent(obj, onEvent, state) {
return true;
}
if (obj.type === 'result' && obj.usage && typeof obj.usage === 'object') {
const usage = {};
if (obj.type === 'result' && isRecord(obj.usage)) {
const usage: Usage = {};
if (typeof obj.usage.inputTokens === 'number') usage.input_tokens = obj.usage.inputTokens;
if (typeof obj.usage.outputTokens === 'number') usage.output_tokens = obj.usage.outputTokens;
if (typeof obj.usage.cacheReadTokens === 'number') {
@ -240,8 +264,8 @@ function handleCursorEvent(obj, onEvent, state) {
return false;
}
function handleCodexEvent(obj, onEvent, state) {
if (!obj || typeof obj !== 'object') return false;
function handleCodexEvent(obj: unknown, onEvent: StreamEventHandler, state: ParserState): boolean {
if (!isRecord(obj)) return false;
if (obj.type === 'error') {
if (!state.codexErrorEmitted) {
@ -275,7 +299,7 @@ function handleCodexEvent(obj, onEvent, state) {
return true;
}
if (obj.type === 'item.started' && obj.item && typeof obj.item === 'object') {
if (obj.type === 'item.started' && isRecord(obj.item)) {
const item = obj.item;
if (item.type === 'command_execution' && typeof item.id === 'string') {
if (!state.codexToolUses.has(item.id)) {
@ -293,7 +317,7 @@ function handleCodexEvent(obj, onEvent, state) {
}
}
if (obj.type === 'item.completed' && obj.item && typeof obj.item === 'object') {
if (obj.type === 'item.completed' && isRecord(obj.item)) {
const item = obj.item;
if (item.type === 'command_execution' && typeof item.id === 'string') {
if (!state.codexToolUses.has(item.id)) {
@ -319,8 +343,7 @@ function handleCodexEvent(obj, onEvent, state) {
if (
obj.type === 'item.completed' &&
obj.item &&
typeof obj.item === 'object' &&
isRecord(obj.item) &&
obj.item.type === 'agent_message' &&
typeof obj.item.text === 'string' &&
obj.item.text.length > 0
@ -329,8 +352,8 @@ function handleCodexEvent(obj, onEvent, state) {
return true;
}
if (obj.type === 'turn.completed' && obj.usage && typeof obj.usage === 'object') {
const usage = {};
if (obj.type === 'turn.completed' && isRecord(obj.usage)) {
const usage: Usage = {};
if (typeof obj.usage.input_tokens === 'number') usage.input_tokens = obj.usage.input_tokens;
if (typeof obj.usage.output_tokens === 'number') usage.output_tokens = obj.usage.output_tokens;
if (typeof obj.usage.cached_input_tokens === 'number') {
@ -343,17 +366,17 @@ function handleCodexEvent(obj, onEvent, state) {
return false;
}
export function createJsonEventStreamHandler(kind, onEvent) {
export function createJsonEventStreamHandler(kind: ParserKind, onEvent: StreamEventHandler) {
let buffer = '';
const state = {
const state: ParserState = {
cursorTextSoFar: '',
openCodeToolUses: new Set(),
codexToolUses: new Set(),
openCodeToolUses: new Set<string>(),
codexToolUses: new Set<string>(),
codexErrorEmitted: false,
};
function handleLine(line) {
let obj;
function handleLine(line: string): void {
let obj: unknown;
try {
obj = JSON.parse(line);
} catch {
@ -369,7 +392,7 @@ export function createJsonEventStreamHandler(kind, onEvent) {
onEvent({ type: 'raw', line });
}
function feed(chunk) {
function feed(chunk: string): void {
buffer += chunk;
let nl;
while ((nl = buffer.indexOf('\n')) !== -1) {
@ -380,7 +403,7 @@ export function createJsonEventStreamHandler(kind, onEvent) {
}
}
function flush() {
function flush(): void {
const rem = buffer.trim();
buffer = '';
if (!rem) return;

View file

@ -1,4 +1,3 @@
// @ts-nocheck
/**
* Anti-slop linter for generated HTML artifacts.
*
@ -18,14 +17,23 @@
* surface badges next to each saved artifact.
*/
/**
* @typedef {Object} LintFinding
* @property {'P0'|'P1'|'P2'} severity
* @property {string} id short stable id (e.g. 'purple-gradient')
* @property {string} message one-line explanation
* @property {string} fix one-line corrective suggestion (for the agent)
* @property {string} [snippet] matched text ( 200 chars), if any
*/
type LintSeverity = 'P0' | 'P1' | 'P2';
export type LintFinding = {
severity: LintSeverity;
id: string;
message: string;
fix: string;
snippet?: string;
};
type CssDeclaration = { prop: string; value: string };
type CssTokenScope = {
selectors: string[];
tokens: Map<string, string>;
isDefault: boolean;
themeKeys: Set<string>;
};
const PURPLE_HEXES = [
// Tailwind violet / purple — the original AI-slop palette.
@ -109,9 +117,8 @@ const DISPLAY_SANS_RE =
* @param {string} html
* @returns {LintFinding[]}
*/
export function lintArtifact(rawHtml) {
/** @type {LintFinding[]} */
const out = [];
export function lintArtifact(rawHtml: unknown): LintFinding[] {
const out: LintFinding[] = [];
if (typeof rawHtml !== 'string' || rawHtml.length === 0) return out;
// Strip HTML comments before any pattern matching — comments often contain
@ -482,8 +489,8 @@ export function lintArtifact(rawHtml) {
.filter((t) => t !== '?');
for (let i = 0; i < themeSeq.length - 2; i++) {
const a = themeSeq[i];
const isLight = (t) => t === 'L' || t === 'HL';
const isDark = (t) => t === 'D' || t === 'HD';
const isLight = (t: string | undefined) => t === 'L' || t === 'HL';
const isDark = (t: string | undefined) => t === 'D' || t === 'HD';
if (
(isLight(a) && isLight(themeSeq[i + 1]) && isLight(themeSeq[i + 2])) ||
(isDark(a) && isDark(themeSeq[i + 1]) && isDark(themeSeq[i + 2]))
@ -509,7 +516,7 @@ export function lintArtifact(rawHtml) {
* @param {LintFinding[]} findings
* @returns {string}
*/
export function renderFindingsForAgent(findings) {
export function renderFindingsForAgent(findings: LintFinding[]): string {
if (findings.length === 0) return '';
const sorted = [...findings].sort((a, b) => severity(a) - severity(b));
const lines = [
@ -529,17 +536,17 @@ export function renderFindingsForAgent(findings) {
return lines.join('\n');
}
function severity(f) {
function severity(f: LintFinding): number {
return f.severity === 'P0' ? 0 : f.severity === 'P1' ? 1 : 2;
}
function clip(s) {
function clip(s: string): string {
if (!s) return '';
const trimmed = s.replace(/\s+/g, ' ').trim();
return trimmed.length > 200 ? trimmed.slice(0, 197) + '…' : trimmed;
}
function escapeRe(s) {
function escapeRe(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
@ -549,7 +556,7 @@ function escapeRe(s) {
// literal `blue`/`cyan` keywords, so both
// `linear-gradient(90deg, #3b82f6, #06b6d4)` and
// `linear-gradient(90deg, blue, cyan)` fire P0.
function detectBlueCyanTrustGradient(html) {
function detectBlueCyanTrustGradient(html: string): string | null {
const re = /linear-gradient\([^)]*\)/gi;
let m;
while ((m = re.exec(html)) !== null) {
@ -627,7 +634,7 @@ function detectBlueCyanTrustGradient(html) {
// pairing (48px, 1px) that an independent per-token cartesian would
// emit.
const ROOT_FONT_PX = 16;
function hasAdequateUppercaseTracking(body, scopes) {
function hasAdequateUppercaseTracking(body: string, scopes?: CssTokenScope[]): boolean {
const themes = buildResolvedThemes(scopes ?? []);
for (const themeMap of themes) {
const resolved = resolveCssVars(body, themeMap);
@ -643,14 +650,17 @@ function hasAdequateUppercaseTracking(body, scopes) {
// CSS source-order cascade — `.eyebrow { letter-spacing: 0.08em;
// letter-spacing: 0.02em }` renders the noncompliant `0.02em` value,
// so the lint must judge against the last declaration, not the first.
function isResolvedTrackingAdequate(body) {
function isResolvedTrackingAdequate(body: string): boolean {
const decls = parseDeclarations(body);
const ls = findLastDecl(decls, 'letter-spacing');
if (!ls) return false;
const lsMatch = /^(-?\d*\.?\d+)\s*(em|px|rem)\b/i.exec(ls.value);
if (!lsMatch) return false;
const v = parseFloat(lsMatch[1]);
const unit = lsMatch[2].toLowerCase();
const valueText = lsMatch[1];
const unitText = lsMatch[2];
if (valueText == null || unitText == null) return false;
const v = parseFloat(valueText);
const unit = unitText.toLowerCase();
if (unit === 'em') return v >= 0.06;
const trackingPx = unit === 'rem' ? v * ROOT_FONT_PX : v;
const fsPx = resolveFontSizePx(decls);
@ -682,12 +692,12 @@ function isResolvedTrackingAdequate(body) {
// per-token cartesian product, which generated impossible cross-theme
// pairings such as `(default-size, dark-track)` and emitted false
// positives on legitimate light/dark theme variants.
function buildResolvedThemes(scopes) {
function buildResolvedThemes(scopes: CssTokenScope[]): Map<string, string>[] {
const themeKeys = new Set(['default']);
for (const scope of scopes) {
for (const k of scope.themeKeys) themeKeys.add(k);
}
const themes = new Map();
const themes = new Map<string, Map<string, string>>();
for (const k of themeKeys) themes.set(k, new Map());
for (const scope of scopes) {
if (scope.isDefault) {
@ -706,13 +716,14 @@ function buildResolvedThemes(scopes) {
return Array.from(themes.values());
}
function isBareGlobalSelector(s) {
function isBareGlobalSelector(s: string): boolean {
return /^(?::root|html|body)$/.test(s);
}
function findLastDecl(decls, prop) {
function findLastDecl(decls: CssDeclaration[], prop: string): CssDeclaration | undefined {
for (let i = decls.length - 1; i >= 0; i--) {
if (decls[i].prop === prop) return decls[i];
const decl = decls[i];
if (decl && decl.prop === prop) return decl;
}
return undefined;
}
@ -721,8 +732,8 @@ function findLastDecl(decls, prop) {
// the property name and skipping custom properties (`--name`). Used by
// the uppercase-tracking lint so substring matches on `letter-spacing`
// or `font-size` cannot collide with token-name declarations.
function parseDeclarations(body) {
const out = [];
function parseDeclarations(body: string): CssDeclaration[] {
const out: CssDeclaration[] = [];
for (const raw of body.split(';')) {
const idx = raw.indexOf(':');
if (idx < 0) continue;
@ -748,13 +759,16 @@ function parseDeclarations(body) {
// against the noncompliant `1em` the browser actually renders, not the
// stale earlier `48px`. CSS cascade is last-write-wins on conflicting
// declarations within a single rule body.
function resolveFontSizePx(decls) {
function resolveFontSizePx(decls: CssDeclaration[]): number | null {
const fs = findLastDecl(decls, 'font-size');
if (!fs) return null;
const m = /^(-?\d*\.?\d+)\s*(px|rem)\b/i.exec(fs.value);
if (!m) return null;
const v = parseFloat(m[1]);
const unit = m[2].toLowerCase();
const valueText = m[1];
const unitText = m[2];
if (valueText == null || unitText == null) return null;
const v = parseFloat(valueText);
const unit = unitText.toLowerCase();
return unit === 'rem' ? v * ROOT_FONT_PX : v;
}
@ -788,8 +802,8 @@ function resolveFontSizePx(decls) {
// name; cross-scope merging happens later in `buildResolvedThemes`,
// where the same source-order cascade is applied between scopes that
// target the same theme.
function extractCssTokens(html) {
const scopes = [];
function extractCssTokens(html: string): CssTokenScope[] {
const scopes: CssTokenScope[] = [];
for (const styleBlock of html.matchAll(/<style[^>]*>([\s\S]*?)<\/style>/gi)) {
const css = (styleBlock[1] ?? '').replace(/\/\*[\s\S]*?\*\//g, '');
const ruleRe = /([^{}]*)\{([^{}]*)\}/g;
@ -807,7 +821,9 @@ function extractCssTokens(html) {
for (const decl of body.split(';').map((d) => d.trim()).filter(Boolean)) {
const dm = /^(--[\w-]+)\s*:\s*(.+)$/.exec(decl);
if (dm) {
tokens.set(dm[1], dm[2].trim());
const tokenName = dm[1];
const tokenValue = dm[2];
if (tokenName != null && tokenValue != null) tokens.set(tokenName, tokenValue.trim());
}
}
if (tokens.size === 0) continue;
@ -826,12 +842,12 @@ function extractCssTokens(html) {
// for the typography pattern this lint cares about, and keeps the
// regex linear-time on artifact-sized inputs.
const VAR_RESOLVE_MAX_DEPTH = 4;
function resolveCssVars(body, tokens) {
function resolveCssVars(body: string, tokens: Map<string, string>): string {
let out = body;
for (let i = 0; i < VAR_RESOLVE_MAX_DEPTH; i++) {
const next = out.replace(
/var\(\s*(--[\w-]+)\s*(?:,\s*([^()]*))?\)/g,
(full, name, fallback) => {
(full: string, name: string, fallback: string | undefined) => {
const v = tokens.get(name);
if (v != null) return v;
if (fallback != null) return fallback.trim();
@ -874,14 +890,14 @@ function resolveCssVars(body, tokens) {
// `:root { --button-bg: #4f46e5 }`) is still the LLM-default
// color hidden behind an arbitrary token name and must stay in
// scope of the indigo scan.
function stripTokenBlocks(input) {
function stripTokenBlocks(input: string): string {
return input.replace(
/(<style[^>]*>)([\s\S]*?)(<\/style>)/gi,
(_m, open, css, close) => `${open}${stripTokenBlocksFromCss(css)}${close}`,
(_m: string, open: string, css: string, close: string) => `${open}${stripTokenBlocksFromCss(css)}${close}`,
);
}
function stripTokenBlocksFromCss(css) {
function stripTokenBlocksFromCss(css: string): string {
// Strip CSS comments before any structural matching: a block like
// `:root { /* brand accent */ --accent: #6366f1; }` would otherwise
// produce a declaration fragment that begins with the comment,
@ -896,12 +912,12 @@ function stripTokenBlocksFromCss(css) {
// `@media` wrapper is preserved with the inner token block stripped,
// so the indigo scan no longer fires on legitimate responsive theme
// declarations.
return cleaned.replace(/([^{}]*)\{([^{}]*)\}/g, (full, selector, body) => {
return cleaned.replace(/([^{}]*)\{([^{}]*)\}/g, (full: string, selector: string, body: string) => {
const sel = (selector || '').trim();
if (!selectorListIsGlobalThemeScope(sel)) return full;
const decls = (body || '')
.split(';')
.map((d) => d.trim())
.map((d: string) => d.trim())
.filter(Boolean);
if (decls.length === 0) return full;
const tokenShaped = decls.every(isTokenShapedDeclaration);
@ -916,18 +932,21 @@ function stripTokenBlocksFromCss(css) {
});
}
function declarationLaundersIndigo(decl) {
function declarationLaundersIndigo(decl: string): boolean {
const m = /^(--[\w-]+)\s*:\s*(.+)$/.exec(decl);
if (!m) return false;
if (m[1].toLowerCase() === '--accent') return false;
const value = m[2].toLowerCase();
const tokenName = m[1];
const tokenValue = m[2];
if (tokenName == null || tokenValue == null) return false;
if (tokenName.toLowerCase() === '--accent') return false;
const value = tokenValue.toLowerCase();
for (const hex of AI_DEFAULT_INDIGO) {
if (value.includes(hex.toLowerCase())) return true;
}
return false;
}
function isTokenShapedDeclaration(decl) {
function isTokenShapedDeclaration(decl: string): boolean {
// CSS custom property — the canonical token shape.
if (/^--[\w-]+\s*:/.test(decl)) return true;
// Global-theme metadata that legitimately accompanies tokens in
@ -937,8 +956,8 @@ function isTokenShapedDeclaration(decl) {
return false;
}
function selectorListIsGlobalThemeScope(selector) {
const parts = selector.split(',').map((s) => s.trim()).filter(Boolean);
function selectorListIsGlobalThemeScope(selector: string): boolean {
const parts = selector.split(',').map((s: string) => s.trim()).filter(Boolean);
if (parts.length === 0) return false;
return parts.every(isGlobalThemeScopeSelector);
}
@ -958,7 +977,7 @@ const GLOBAL_THEME_ATTRIBUTES = new Set([
'data-mode',
]);
function isGlobalThemeScopeSelector(s) {
function isGlobalThemeScopeSelector(s: string): boolean {
// :root / html / body, optionally suffixed with a single attribute
// selector. The bare form (no attribute) is always a global theme
// scope; the prefixed form is only a theme scope when the attribute
@ -973,7 +992,8 @@ function isGlobalThemeScopeSelector(s) {
}
// Bare attribute selector restricted to known global-theme switches.
const bareAttr = /^\[([a-zA-Z-]+)(?:[*^$|~]?=[^\]]*)?\]$/.exec(s);
if (bareAttr && GLOBAL_THEME_ATTRIBUTES.has(bareAttr[1].toLowerCase())) {
const bareAttrName = bareAttr?.[1];
if (bareAttrName && GLOBAL_THEME_ATTRIBUTES.has(bareAttrName.toLowerCase())) {
return true;
}
return false;

View file

@ -1,11 +1,3 @@
// @ts-nocheck
// TypeScript is suppressed because @modelcontextprotocol/sdk@1.x expects
// Zod schemas for tool definitions, but we pass plain JSON Schema objects.
// The runtime contract is identical; there is no type-safety regression -
// the nocheck just avoids a blanket of incorrect Zod-vs-object type errors
// that would obscure real mistakes. Remove once the SDK adds a JSON Schema
// overload or we migrate to a Zod-based schema builder.
//
// `od mcp` - stdio MCP server that proxies read-only tool calls to the
// running daemon's HTTP API. Lets a coding agent in a *different* repo
// (Claude Code, Cursor, Zed) pull files from a local Open Design
@ -29,6 +21,23 @@ import {
const SERVER_NAME = 'open-design';
const SERVER_VERSION = '0.2.0';
type JsonObject = Record<string, unknown>;
interface RunMcpOptions { daemonUrl: string | URL }
interface CatalogItem { id: string; name?: string; title?: string; description?: string; summary?: string }
interface SkillsPayload { skills?: CatalogItem[] }
interface DesignSystemsPayload { designSystems?: CatalogItem[] }
interface ResourcePayload { skill?: { body?: string; content?: string }; designSystem?: { body?: string; content?: string }; body?: string; content?: string }
interface ProjectSummary { id: string; name: string; metadata?: JsonObject }
interface ProjectsPayload { projects?: ProjectSummary[] }
interface ProjectPayload { project?: ProjectSummary; id?: string; name?: string; metadata?: JsonObject }
interface ActiveContext { active?: boolean; projectId?: string; projectName?: string | null; fileName?: string | null; ageMs?: number | null }
type ResolvedProject = { id: string; name: string; source: 'uuid' | 'exact' | 'slug' | 'substring' };
interface ProjectListCache { baseUrl: string; t: number; list: ProjectSummary[] }
interface McpArgs extends JsonObject { project?: unknown; entry?: unknown; include?: unknown; maxBytes?: unknown; path?: unknown; offset?: unknown; limit?: unknown; since?: unknown; query?: unknown; pattern?: unknown; max?: unknown }
interface ProjectFileBundleEntry { name: string; mime: string; size: number | null; content: string | null; binary: boolean }
interface BundleInput { project: ProjectPayload | ProjectSummary; entry: string; files: ProjectFileBundleEntry[]; truncated: boolean; active: ActiveContext | null; resolved?: ResolvedProject | null }
interface ErrorWithCode { message?: string; code?: string; cause?: { code?: string } }
// Mimes whose body we surface as MCP `text` content. Everything else
// returns a clear error directing the caller at list_files for
// metadata, until phase 2 adds binary support.
@ -195,7 +204,7 @@ const TOOL_DEFS = [
// tokens on every turn.
];
export async function runMcpStdio({ daemonUrl }) {
export async function runMcpStdio({ daemonUrl }: RunMcpOptions): Promise<void> {
const baseUrl = String(daemonUrl).replace(/\/$/, '');
const server = new Server(
@ -257,8 +266,8 @@ export async function runMcpStdio({ daemonUrl }) {
server.setRequestHandler(ListResourcesRequestSchema, async () => {
const [skillsData, dsData] = await Promise.all([
getJson(`${baseUrl}/api/skills`).catch(() => ({ skills: [] })),
getJson(`${baseUrl}/api/design-systems`).catch(() => ({ designSystems: [] })),
getJson<SkillsPayload>(`${baseUrl}/api/skills`).catch((): SkillsPayload => ({ skills: [] })),
getJson<DesignSystemsPayload>(`${baseUrl}/api/design-systems`).catch((): DesignSystemsPayload => ({ designSystems: [] })),
]);
const resources = [
{
@ -272,7 +281,7 @@ export async function runMcpStdio({ daemonUrl }) {
resources.push({
uri: `od://skills/${encodeURIComponent(s.id)}/SKILL.md`,
name: `Skill: ${s.name || s.id}`,
description: oneLine(s.description),
description: oneLine(s.description) ?? '',
mimeType: 'text/markdown',
});
}
@ -280,7 +289,7 @@ export async function runMcpStdio({ daemonUrl }) {
resources.push({
uri: `od://design-systems/${encodeURIComponent(d.id)}/DESIGN.md`,
name: `Design system: ${d.title || d.name || d.id}`,
description: oneLine(d.summary),
description: oneLine(d.summary) ?? '',
mimeType: 'text/markdown',
});
}
@ -290,7 +299,7 @@ export async function runMcpStdio({ daemonUrl }) {
server.setRequestHandler(ReadResourceRequestSchema, async (req) => {
const uri = req.params?.uri;
if (uri === 'od://focus/active') {
const data = await getJson(`${baseUrl}/api/active`);
const data = await getJson<ActiveContext>(`${baseUrl}/api/active`);
return {
contents: [
{
@ -305,9 +314,9 @@ export async function runMcpStdio({ daemonUrl }) {
if (!m) {
throw new Error(`unsupported resource URI: ${uri}`);
}
const [, kind, id] = m;
const [, kind, id] = m as [string, 'skills' | 'design-systems', string, string];
const route = kind === 'skills' ? 'skills' : 'design-systems';
const data = await getJson(
const data = await getJson<ResourcePayload>(
`${baseUrl}/api/${route}/${encodeURIComponent(decodeURIComponent(id))}`,
);
const text =
@ -331,13 +340,13 @@ export async function runMcpStdio({ daemonUrl }) {
server.setRequestHandler(CallToolRequestSchema, async (req) => {
const name = req.params?.name;
const args = req.params?.arguments ?? {};
const args: McpArgs = (req.params?.arguments ?? {}) as McpArgs;
try {
switch (name) {
case 'list_projects':
return ok(await getJson(`${baseUrl}/api/projects`));
return ok(await getJson<ProjectsPayload>(`${baseUrl}/api/projects`));
case 'get_active_context': {
const data = await getJson(`${baseUrl}/api/active`);
const data = await getJson<ActiveContext>(`${baseUrl}/api/active`);
if (!data || data.active === false) {
return ok({
active: false,
@ -348,7 +357,7 @@ export async function runMcpStdio({ daemonUrl }) {
}
case 'get_project': {
const { id, resolved, active } = await resolveProjectArg(baseUrl, args.project);
const data = await getJson(`${baseUrl}/api/projects/${encodeURIComponent(id)}`);
const data = await getJson<ProjectPayload>(`${baseUrl}/api/projects/${encodeURIComponent(id)}`);
const project = data?.project ?? data;
return ok(
withActiveEcho(
@ -365,7 +374,7 @@ export async function runMcpStdio({ daemonUrl }) {
case 'list_files': {
const { id, resolved, active } = await resolveProjectArg(baseUrl, args.project);
const params = new URLSearchParams();
if (Number.isFinite(args.since)) params.set('since', String(args.since));
if (typeof args.since === 'number' && Number.isFinite(args.since)) params.set('since', String(args.since));
const qs = params.toString();
const url = `${baseUrl}/api/projects/${encodeURIComponent(id)}/files${qs ? `?${qs}` : ''}`;
return ok(withActiveEcho(await getJson(url), active, resolved));
@ -380,8 +389,8 @@ export async function runMcpStdio({ daemonUrl }) {
path = active.fileName;
}
requireString(path, 'path');
const offset = Number.isFinite(args.offset) ? Math.max(0, Math.floor(args.offset)) : 0;
const limit = Number.isFinite(args.limit) ? Math.max(1, Math.floor(args.limit)) : 2000;
const offset = typeof args.offset === 'number' && Number.isFinite(args.offset) ? Math.max(0, Math.floor(args.offset)) : 0;
const limit = typeof args.limit === 'number' && Number.isFinite(args.limit) ? Math.max(1, Math.floor(args.limit)) : 2000;
return await getFile(baseUrl, id, path, active, resolved, offset, limit);
}
case 'get_artifact':
@ -431,17 +440,17 @@ export async function runMcpStdio({ daemonUrl }) {
});
}
function ok(payload) {
function ok(payload: unknown) {
const text =
typeof payload === 'string' ? payload : JSON.stringify(payload, null, 2);
return { content: [{ type: 'text', text }] };
}
function errorResult(message) {
function errorResult(message: string) {
return { isError: true, content: [{ type: 'text', text: message }] };
}
function requireString(v, name) {
function requireString(v: unknown, name: string): asserts v is string {
if (typeof v !== 'string' || v.length === 0) {
throw new Error(`${name} is required (string).`);
}
@ -450,7 +459,7 @@ function requireString(v, name) {
// Resource description renderers in some MCP UIs collapse whitespace
// poorly; keep our descriptions on a single line so they don't break
// the catalog list layout.
function oneLine(s) {
function oneLine(s: unknown): string | undefined {
if (typeof s !== 'string') return undefined;
return s.replace(/\s+/g, ' ').trim().slice(0, 200) || undefined;
}
@ -462,9 +471,9 @@ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
// each one re-fetches /api/projects. The TTL is short so a project
// renamed in the Open Design UI shows up within a few seconds.
const PROJECT_LIST_TTL_MS = 5000;
let projectListCache = null;
let projectListCache: ProjectListCache | null = null;
async function fetchProjectList(baseUrl) {
async function fetchProjectList(baseUrl: string): Promise<ProjectSummary[]> {
const now = Date.now();
if (
projectListCache &&
@ -473,7 +482,7 @@ async function fetchProjectList(baseUrl) {
) {
return projectListCache.list;
}
const data = await getJson(`${baseUrl}/api/projects`);
const data = await getJson<ProjectsPayload>(`${baseUrl}/api/projects`);
const list = Array.isArray(data?.projects) ? data.projects : [];
projectListCache = { baseUrl, t: now, list };
return list;
@ -484,17 +493,17 @@ async function fetchProjectList(baseUrl) {
// caller, the active-context payload that was used. Throws a clear
// error when neither is available so the agent can prompt the user
// rather than guessing.
async function resolveProjectArg(baseUrl, arg) {
async function resolveProjectArg(baseUrl: string, arg: unknown): Promise<{ id: string; resolved: ResolvedProject | null; active: ActiveContext | null }> {
if (typeof arg === 'string' && arg.length > 0) {
const resolved = await resolveProjectId(baseUrl, arg);
return { id: resolved.id, resolved, active: null };
}
let active;
let active: ActiveContext;
try {
active = await getJson(`${baseUrl}/api/active`);
active = await getJson<ActiveContext>(`${baseUrl}/api/active`);
} catch (err) {
throw new Error(
`project arg omitted and active context lookup failed: ${err && err.message ? err.message : err}. Pass project="<id-or-name>".`,
`project arg omitted and active context lookup failed: ${errorMessage(err)}. Pass project="<id-or-name>".`,
);
}
if (!active || active.active === false || !active.projectId) {
@ -505,7 +514,7 @@ async function resolveProjectArg(baseUrl, arg) {
return { id: active.projectId, resolved: null, active };
}
async function resolveProjectId(baseUrl, arg) {
async function resolveProjectId(baseUrl: string, arg: unknown): Promise<ResolvedProject> {
if (typeof arg !== 'string' || !arg) {
throw new Error('project is required (string).');
}
@ -517,7 +526,7 @@ async function resolveProjectId(baseUrl, arg) {
}
const lower = arg.toLowerCase();
const norm = (s) =>
const norm = (s: unknown): string =>
String(s || '')
.toLowerCase()
.replace(/\s*\(\d+\)\s*$/, '')
@ -525,15 +534,15 @@ async function resolveProjectId(baseUrl, arg) {
const target = norm(arg);
const exact = list.filter((p) => String(p.name || '').toLowerCase() === lower);
if (exact.length === 1) return { id: exact[0].id, name: exact[0].name, source: 'exact' as const };
if (exact.length === 1) { const p = exact[0]!; return { id: p.id, name: p.name, source: 'exact' as const }; }
const slugged = list.filter((p) => norm(p.name) === target);
if (slugged.length === 1) return { id: slugged[0].id, name: slugged[0].name, source: 'slug' as const };
if (slugged.length === 1) { const p = slugged[0]!; return { id: p.id, name: p.name, source: 'slug' as const }; }
const subs = list.filter((p) =>
String(p.name || '').toLowerCase().includes(lower),
);
if (subs.length === 1) return { id: subs[0].id, name: subs[0].name, source: 'substring' as const };
if (subs.length === 1) { const p = subs[0]!; return { id: p.id, name: p.name, source: 'substring' as const }; }
if (subs.length > 1) {
const opts = subs.map((p) => `${p.name} (${p.id})`).join(', ');
throw new Error(
@ -543,16 +552,16 @@ async function resolveProjectId(baseUrl, arg) {
throw new Error(`no project matches "${arg}"`);
}
async function getJson(url) {
async function getJson<T>(url: string): Promise<T> {
const resp = await fetch(url);
if (!resp.ok) {
const body = await safeText(resp);
throw new Error(`daemon ${resp.status} on ${url}: ${body || resp.statusText}`);
}
return await resp.json();
return (await resp.json()) as T;
}
async function getFile(baseUrl, project, relPath, active, resolved?, offset = 0, limit = 2000) {
async function getFile(baseUrl: string, project: string, relPath: string, active: ActiveContext | null, resolved?: ResolvedProject | null, offset = 0, limit = 2000) {
const segments = String(relPath)
.split('/')
.filter((s) => s.length > 0)
@ -565,9 +574,7 @@ async function getFile(baseUrl, project, relPath, active, resolved?, offset = 0,
`daemon ${resp.status} on ${url}: ${body || resp.statusText}`,
);
}
const mime = (resp.headers.get('content-type') || 'application/octet-stream')
.split(';')[0]
.trim();
const mime = ((resp.headers.get('content-type') || 'application/octet-stream').split(';')[0] ?? 'application/octet-stream').trim();
if (!isTextualMime(mime)) {
return errorResult(
`file at "${relPath}" has mime "${mime}"; binary content is not yet supported by od mcp. Use list_files to inspect its metadata.`,
@ -605,7 +612,7 @@ async function getFile(baseUrl, project, relPath, active, resolved?, offset = 0,
// project came from /api/active. Plain pass-through when the caller
// supplied project explicitly - keeps token overhead at zero for the
// explicit path.
function withActiveEcho(payload, active, resolved?) {
function withActiveEcho<T extends JsonObject>(payload: T, active: ActiveContext | null, resolved?: ResolvedProject | null): T & JsonObject {
const result = active ? { ...payload, usedActiveContext: activeEchoPayload(active) } : payload;
if (resolved && (resolved.source === 'slug' || resolved.source === 'substring')) {
return { ...result, resolvedProject: { id: resolved.id, name: resolved.name } };
@ -613,7 +620,7 @@ function withActiveEcho(payload, active, resolved?) {
return result;
}
function activeEchoPayload(active) {
function activeEchoPayload(active: ActiveContext) {
return {
projectId: active.projectId,
projectName: active.projectName ?? null,
@ -622,7 +629,7 @@ function activeEchoPayload(active) {
};
}
function formatActiveEchoLine(active, resolvedPath) {
function formatActiveEchoLine(active: ActiveContext, resolvedPath: string): string {
const proj = active.projectName || active.projectId;
const note = `[od:active-context project="${proj}" file="${resolvedPath}"]`;
return active.fileName === resolvedPath
@ -637,7 +644,7 @@ const MAX_FILES = 200;
// Tracks total textual content bytes accumulated; binary stubs don't
// count (their content is null). Once we cross the cap the caller
// stops fetching and stamps `truncated: true` on the bundle.
function totalTextBytes(files) {
function totalTextBytes(files: ProjectFileBundleEntry[]): number {
let n = 0;
for (const f of files) {
if (!f.binary && typeof f.content === 'string') n += f.content.length;
@ -645,27 +652,28 @@ function totalTextBytes(files) {
return n;
}
async function getArtifact(baseUrl, projectArg, entryArg, includeMode, maxBytesArg) {
async function getArtifact(baseUrl: string, projectArg: unknown, entryArg: unknown, includeMode: unknown, maxBytesArg: unknown) {
const include = includeMode == null || includeMode === '' ? 'auto' : includeMode;
if (!VALID_INCLUDE_MODES.has(include)) {
if (typeof include !== 'string' || !VALID_INCLUDE_MODES.has(include)) {
return errorResult(
`invalid include "${includeMode}"; expected one of: auto, all, shallow`,
);
}
const maxBytes =
Number.isFinite(maxBytesArg) && maxBytesArg > 0 ? Number(maxBytesArg) : DEFAULT_MAX_BYTES;
typeof maxBytesArg === 'number' && Number.isFinite(maxBytesArg) && maxBytesArg > 0 ? maxBytesArg : DEFAULT_MAX_BYTES;
const { id, active, resolved } = await resolveProjectArg(baseUrl, projectArg);
const data = await getJson(`${baseUrl}/api/projects/${encodeURIComponent(id)}`);
const project = data?.project ?? data;
const data = await getJson<ProjectPayload>(`${baseUrl}/api/projects/${encodeURIComponent(id)}`);
const project = (data.project ?? data) as ProjectSummary;
// Active-file beats project default entry when project also came
// from active context - if the user is on landing.html and asks
// "bundle this", they mean landing.html, not whatever
// metadata.entryFile happens to be.
const explicitEntry = typeof entryArg === 'string' && entryArg.length > 0;
const entry = explicitEntry
? entryArg
: (active && active.fileName) || project?.metadata?.entryFile;
const metadataEntry = typeof project.metadata?.entryFile === 'string' ? project.metadata.entryFile : undefined;
const entry: string | undefined = explicitEntry
? String(entryArg)
: (active && active.fileName) || metadataEntry;
if (!entry) {
return errorResult(
`no entry file: pass entry="..." or set the project's metadata.entryFile`,
@ -677,15 +685,15 @@ async function getArtifact(baseUrl, projectArg, entryArg, includeMode, maxBytesA
try {
file = await fetchProjectFile(baseUrl, id, entry);
} catch (err) {
return errorResult(err && err.message ? err.message : String(err));
return errorResult(errorMessage(err));
}
return okBundle({ project, entry, files: [file], truncated: false, active, resolved });
}
if (include === 'all') {
const meta = await getJson(`${baseUrl}/api/projects/${encodeURIComponent(id)}/files`);
const meta = await getJson<{ files?: Array<{ name: string }> }>(`${baseUrl}/api/projects/${encodeURIComponent(id)}/files`);
const allFiles = Array.isArray(meta?.files) ? meta.files : [];
const fetched = [];
const fetched: ProjectFileBundleEntry[] = [];
let truncated = false;
for (const f of allFiles) {
if (fetched.length >= MAX_FILES || totalTextBytes(fetched) >= maxBytes) {
@ -710,20 +718,20 @@ async function getArtifact(baseUrl, projectArg, entryArg, includeMode, maxBytesA
try {
entryFile = await fetchProjectFile(baseUrl, id, entry);
} catch (err) {
return errorResult(err && err.message ? err.message : String(err));
return errorResult(errorMessage(err));
}
const MAX_DEPTH = 3;
const visited = new Set([entry]);
const fetched = [entryFile];
let truncated = false;
let frontier = [];
let frontier: string[] = [];
if (isTextualMime(entryFile.mime)) {
frontier = extractRelativeRefs(entryFile.content || '', entry, entryFile.mime).filter(
(r) => !visited.has(r),
);
}
outer: for (let depth = 1; depth < MAX_DEPTH && frontier.length > 0; depth++) {
const next = [];
const next: string[] = [];
for (const refPath of frontier) {
if (visited.has(refPath)) continue;
visited.add(refPath);
@ -757,7 +765,7 @@ async function getArtifact(baseUrl, projectArg, entryArg, includeMode, maxBytesA
// failure of the whole bundle.
class BudgetExceededError extends Error {}
async function fetchProjectFile(baseUrl, projectId, relPath, remainingBytes = Infinity) {
async function fetchProjectFile(baseUrl: string, projectId: string, relPath: string, remainingBytes = Infinity): Promise<ProjectFileBundleEntry> {
const segments = String(relPath)
.split('/')
.filter((s) => s.length > 0)
@ -768,9 +776,7 @@ async function fetchProjectFile(baseUrl, projectId, relPath, remainingBytes = In
const body = await safeText(resp);
throw new Error(`daemon ${resp.status} on ${url}: ${body || resp.statusText}`);
}
const mime = (resp.headers.get('content-type') || 'application/octet-stream')
.split(';')[0]
.trim();
const mime = ((resp.headers.get('content-type') || 'application/octet-stream').split(';')[0] ?? 'application/octet-stream').trim();
const headerSize = Number(resp.headers.get('content-length'));
const size = Number.isFinite(headerSize) && headerSize >= 0 ? headerSize : null;
if (!isTextualMime(mime)) {
@ -813,25 +819,25 @@ const JS_REF_PATTERNS = [
// `srcset` can list multiple comma-separated candidates.
const SRCSET_PATTERN = /\bsrcset=["']([^"']+)["']/gi;
function isJsLike(mime, fromPath) {
function isJsLike(mime: string | undefined, fromPath: string): boolean {
if (mime && /javascript|typescript/i.test(mime)) return true;
return /\.(?:m?jsx?|tsx?|cjs)$/i.test(fromPath);
}
function isCssLike(mime, fromPath) {
function isCssLike(mime: string | undefined, fromPath: string): boolean {
if (mime && /^text\/css\b/i.test(mime)) return true;
return /\.css$/i.test(fromPath);
}
function isHtmlLike(mime, fromPath) {
function isHtmlLike(mime: string | undefined, fromPath: string): boolean {
if (mime && /^text\/html\b/i.test(mime)) return true;
return /\.html?$/i.test(fromPath);
}
function extractRelativeRefs(text, fromPath, fromMime) {
function extractRelativeRefs(text: string, fromPath: string, fromMime: string): string[] {
if (!text) return [];
const refs = new Set();
const runPatterns = [];
const refs = new Set<string>();
const runPatterns: RegExp[] = [];
if (isHtmlLike(fromMime, fromPath)) {
runPatterns.push(...HTML_REF_PATTERNS, ...CSS_REF_PATTERNS);
}
@ -847,7 +853,7 @@ function extractRelativeRefs(text, fromPath, fromMime) {
runPatterns.push(...CSS_REF_PATTERNS);
}
const candidates = [];
const candidates: string[] = [];
for (const re of runPatterns) {
for (const m of text.matchAll(re)) {
const ref = (m[1] || '').trim();
@ -890,7 +896,7 @@ function extractRelativeRefs(text, fromPath, fromMime) {
return [...refs];
}
function okBundle(bundle) {
function okBundle(bundle: BundleInput) {
const payload = {
entryFile: bundle.entry,
projectId: bundle.project?.id,
@ -908,12 +914,12 @@ function okBundle(bundle) {
return ok(withActiveEcho(payload, bundle.active, bundle.resolved));
}
function isTextualMime(mime) {
function isTextualMime(mime: string | undefined): boolean {
if (!mime) return false;
return TEXTUAL_MIME_PATTERNS.some((re) => re.test(mime));
}
async function safeText(resp) {
async function safeText(resp: Response): Promise<string> {
try {
return await resp.text();
} catch {
@ -921,14 +927,19 @@ async function safeText(resp) {
}
}
function formatError(err, daemonUrl) {
const code = err && (err.cause?.code || err.code);
const msg = err && err.message ? err.message : String(err);
function formatError(err: unknown, daemonUrl: string): string {
const e = err as ErrorWithCode | null | undefined;
const code = e && (e.cause?.code || e.code);
const msg = errorMessage(err);
if (code === 'ECONNREFUSED' || code === 'ENOTFOUND') {
return `cannot reach the Open Design daemon at ${daemonUrl}. Is it running? Start it with \`pnpm tools-dev\`.`;
}
return msg;
}
function errorMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
// Exported for unit tests only.
export { extractRelativeRefs, resolveProjectId, resolveProjectArg, withActiveEcho, fetchProjectFile, getArtifact, getFile };

View file

@ -1,4 +1,3 @@
// @ts-nocheck
// Per-provider credentials for the media dispatcher.
//
// The frontend Settings dialog pushes API keys here via PUT
@ -43,8 +42,20 @@ import { MEDIA_PROVIDERS } from './media-models.js';
import { expandHomePrefix } from './home-expansion.js';
const PROVIDER_IDS = MEDIA_PROVIDERS.map((p) => p.id);
type ProviderEntry = { apiKey?: string; baseUrl?: string; model?: string };
type ProviderMap = Record<string, ProviderEntry>;
type JsonRecord = Record<string, unknown>;
type OAuthCredential = { apiKey: string; source: string };
const ENV_KEYS = {
function isRecord(value: unknown): value is JsonRecord {
return value !== null && typeof value === 'object';
}
function errorCode(err: unknown): string | undefined {
return isRecord(err) && typeof err.code === 'string' ? err.code : undefined;
}
const ENV_KEYS: Record<string, string[]> = {
// OPENAI_API_KEY is the canonical env for the standard OpenAI API.
// AZURE_API_KEY / AZURE_OPENAI_API_KEY are the canonical envs Azure
// OpenAI examples use — we share the openai provider slot so a user
@ -87,7 +98,7 @@ const ENV_KEYS = {
// configFile() is on the read path and a missing/unwritable directory
// is a normal "no config yet" condition handled by readStored(); the
// write path's mkdir(recursive) creates the directory on first use.
function resolveOverrideDir(raw, projectRoot) {
function resolveOverrideDir(raw: string, projectRoot: string): string {
// Share expandHomePrefix with resolveDataDir (server.ts) so OD_DATA_DIR
// and OD_MEDIA_CONFIG_DIR cannot split state under a $HOME-style value.
// A launcher passing OD_DATA_DIR=$HOME/.open-design without a shell to
@ -101,14 +112,14 @@ function resolveOverrideDir(raw, projectRoot) {
: path.resolve(projectRoot, expanded);
}
function envOverrideDir(envName, projectRoot) {
function envOverrideDir(envName: string, projectRoot: string): string | null {
const raw = process.env[envName];
if (typeof raw !== 'string') return null;
const trimmed = raw.trim();
return trimmed ? resolveOverrideDir(trimmed, projectRoot) : null;
}
function configFile(projectRoot) {
function configFile(projectRoot: string): string {
// Precedence: explicit media-config override > general data dir > default.
const dir =
envOverrideDir('OD_MEDIA_CONFIG_DIR', projectRoot)
@ -117,27 +128,27 @@ function configFile(projectRoot) {
return path.join(dir, 'media-config.json');
}
async function readStored(projectRoot) {
async function readStored(projectRoot: string): Promise<ProviderMap> {
try {
const raw = await readFile(configFile(projectRoot), 'utf8');
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === 'object' && parsed.providers) {
return parsed.providers;
if (isRecord(parsed) && isRecord(parsed.providers)) {
return parsed.providers as ProviderMap;
}
return {};
} catch (err) {
if (err && err.code === 'ENOENT') return {};
if (errorCode(err) === 'ENOENT') return {};
throw err;
}
}
async function writeStored(projectRoot, providers) {
async function writeStored(projectRoot: string, providers: ProviderMap): Promise<void> {
const file = configFile(projectRoot);
await mkdir(path.dirname(file), { recursive: true });
await writeFile(file, JSON.stringify({ providers }, null, 2), 'utf8');
}
function readEnvKey(providerId) {
function readEnvKey(providerId: string): string | null {
const keys = ENV_KEYS[providerId];
if (!keys) return null;
for (const k of keys) {
@ -147,29 +158,29 @@ function readEnvKey(providerId) {
return null;
}
function readNestedString(obj, keys) {
let cur = obj;
function readNestedString(obj: unknown, keys: string[]): string {
let cur: unknown = obj;
for (const key of keys) {
if (!cur || typeof cur !== 'object') return '';
if (!isRecord(cur)) return '';
cur = cur[key];
}
return typeof cur === 'string' && cur.trim() ? cur.trim() : '';
}
async function readJsonIfPresent(file) {
async function readJsonIfPresent(file: string): Promise<JsonRecord | null> {
try {
const raw = await readFile(file, 'utf8');
const parsed = JSON.parse(raw);
return parsed && typeof parsed === 'object' ? parsed : null;
return isRecord(parsed) ? parsed : null;
} catch (err) {
if (err && err.code === 'ENOENT') return null;
if (errorCode(err) === 'ENOENT') return null;
// Auth files are best-effort fallbacks. A malformed local auth cache
// should not break the Settings page or hide stored provider config.
return null;
}
}
function tokenFromHermesAuth(data) {
function tokenFromHermesAuth(data: unknown): string {
const providerToken = readNestedString(data, [
'providers',
'openai-codex',
@ -179,8 +190,8 @@ function tokenFromHermesAuth(data) {
if (providerToken) return providerToken;
const pool =
data && typeof data === 'object'
? data.credential_pool && data.credential_pool['openai-codex']
isRecord(data) && isRecord(data.credential_pool)
? data.credential_pool['openai-codex']
: null;
if (Array.isArray(pool)) {
for (const item of pool) {
@ -191,7 +202,7 @@ function tokenFromHermesAuth(data) {
return '';
}
function tokenFromCodexAuth(data) {
function tokenFromCodexAuth(data: unknown): { token: string; source: string } | null {
const oauthToken = readNestedString(data, ['tokens', 'access_token']);
if (oauthToken) return { token: oauthToken, source: 'oauth-codex' };
@ -201,7 +212,7 @@ function tokenFromCodexAuth(data) {
return null;
}
async function resolveOpenAIOAuthCredential() {
async function resolveOpenAIOAuthCredential(): Promise<OAuthCredential | null> {
const home = os.homedir();
const hermesAuth = await readJsonIfPresent(
path.join(home, '.hermes', 'auth.json'),
@ -227,7 +238,7 @@ async function resolveOpenAIOAuthCredential() {
* then OpenAI/Codex OAuth for the OpenAI media provider.
* Returns { apiKey, baseUrl } where either may be empty string.
*/
export async function resolveProviderConfig(projectRoot, providerId) {
export async function resolveProviderConfig(projectRoot: string, providerId: string): Promise<ProviderEntry> {
const stored = await readStored(projectRoot);
const entry = stored[providerId] || {};
const envKey = readEnvKey(providerId);
@ -249,9 +260,9 @@ export async function resolveProviderConfig(projectRoot, providerId) {
* frontend can show "••••" + a "configured" indicator without leaking
* the secret back into the DOM.
*/
export async function readMaskedConfig(projectRoot) {
export async function readMaskedConfig(projectRoot: string): Promise<{ providers: Record<string, { configured: boolean; source: string; apiKeyTail: string; baseUrl: string; model?: string }> }> {
const stored = await readStored(projectRoot);
const providers = {};
const providers: Record<string, { configured: boolean; source: string; apiKeyTail: string; baseUrl: string; model?: string }> = {};
for (const id of PROVIDER_IDS) {
const entry = stored[id] || {};
const envKey = readEnvKey(id);
@ -266,7 +277,7 @@ export async function readMaskedConfig(projectRoot) {
// Show last 4 chars only when stored locally; never echo env-var
// or OAuth secrets so power users don't accidentally see them in
// the DOM.
apiKeyTail: hasStoredKey ? entry.apiKey.slice(-4) : '',
apiKeyTail: hasStoredKey && entry.apiKey ? entry.apiKey.slice(-4) : '',
baseUrl: entry.baseUrl || '',
...(typeof entry.model === 'string' && entry.model.trim()
? { model: entry.model.trim() }
@ -288,13 +299,13 @@ export async function readMaskedConfig(projectRoot) {
* pushing `{providers: {}}` onto a daemon that had keys from a
* previous session) without silently destroying the user's data.
*/
export async function writeConfig(projectRoot, body) {
const incoming = body && typeof body === 'object' ? body.providers || {} : {};
const force = Boolean(body && typeof body === 'object' && body.force === true);
const next = {};
export async function writeConfig(projectRoot: string, body: unknown) {
const incoming = isRecord(body) && isRecord(body.providers) ? body.providers : {};
const force = Boolean(isRecord(body) && body.force === true);
const next: ProviderMap = {};
for (const id of PROVIDER_IDS) {
const entry = incoming[id];
if (!entry || typeof entry !== 'object') continue;
if (!isRecord(entry)) continue;
const apiKey =
typeof entry.apiKey === 'string' && entry.apiKey.trim()
? entry.apiKey.trim()
@ -323,7 +334,7 @@ export async function writeConfig(projectRoot, body) {
if (!force) {
const err = new Error(
`refusing to wipe ${priorIds.length} configured provider(s) without force=true: ${priorIds.join(', ')}`,
);
) as Error & { status: number };
err.status = 409;
throw err;
}

View file

@ -1,14 +1,34 @@
// @ts-nocheck
// Daemon-side mirror of src/media/models.ts. We keep this in plain JS so
// node imports are native and the daemon never needs a TS toolchain at
// runtime. The two files are kept in sync by hand — any model added to
// Daemon-side mirror of src/media/models.ts. The two files are kept in sync by hand — any model added to
// src/media/models.ts must be added here too. Drift is enforced by
// `node scripts/verify-media-models.mjs` (also exposed as
// `npm run verify:media-models`); CI should call it before publish so
// the moment one side adds a model and the other doesn't, the build
// fails with a precise diff.
export const MEDIA_PROVIDERS = [
export type MediaSurface = 'image' | 'video' | 'audio';
export type AudioKind = 'music' | 'speech' | 'sfx';
export type MediaProvider = {
id: string;
label: string;
hint: string;
integrated: boolean;
defaultBaseUrl?: string;
credentialsRequired?: boolean;
settingsVisible?: boolean;
supportsCustomModel?: boolean;
};
export type MediaModel = {
id: string;
label: string;
hint: string;
provider: string;
caps: string[];
default?: boolean;
};
export const MEDIA_PROVIDERS: MediaProvider[] = [
{ id: 'openai', label: 'OpenAI', hint: 'gpt-image-2 / dall-e-3', integrated: true, defaultBaseUrl: 'https://api.openai.com/v1' },
{ id: 'volcengine', label: 'Volcengine Ark (Doubao)', hint: 'Seedance 2.0 / Seedream', integrated: true, defaultBaseUrl: 'https://ark.cn-beijing.volces.com/api/v3' },
{ id: 'grok', label: 'xAI Grok Imagine', hint: 'grok-imagine — image + video with native audio', integrated: true, defaultBaseUrl: 'https://api.x.ai/v1' },
@ -29,7 +49,7 @@ export const MEDIA_PROVIDERS = [
{ id: 'stub', label: 'Stub (placeholder)', hint: 'Deterministic local placeholder bytes', integrated: true },
];
export const IMAGE_MODELS = [
export const IMAGE_MODELS: MediaModel[] = [
{ id: 'gpt-image-2', label: 'gpt-image-2', hint: 'OpenAI · 4K, native multimodal', provider: 'openai', caps: ['t2i', 'i2i', 'inpaint'], default: true },
{ id: 'gpt-image-1.5', label: 'gpt-image-1.5', hint: 'OpenAI · 4× faster than gpt-image-1', provider: 'openai', caps: ['t2i', 'i2i', 'inpaint'] },
{ id: 'gpt-image-1', label: 'gpt-image-1', hint: 'OpenAI · ChatGPT native', provider: 'openai', caps: ['t2i', 'i2i', 'inpaint'] },
@ -61,7 +81,7 @@ export const IMAGE_MODELS = [
{ id: 'midjourney-v7', label: 'midjourney-v7', hint: 'Midjourney · via proxy', provider: 'midjourney', caps: ['t2i'] },
];
export const VIDEO_MODELS = [
export const VIDEO_MODELS: MediaModel[] = [
{ id: 'doubao-seedance-2-0-260128', label: 'seedance-2.0', hint: 'ByteDance · t2v + i2v + audio', provider: 'volcengine', caps: ['t2v', 'i2v', 'audio'], default: true },
{ id: 'doubao-seedance-2-0-fast-260128', label: 'seedance-2.0-fast', hint: 'ByteDance · faster, cheaper', provider: 'volcengine', caps: ['t2v', 'i2v', 'audio'] },
{ id: 'doubao-seedance-1-0-pro-250528', label: 'seedance-1.0-pro', hint: 'ByteDance · 1.0', provider: 'volcengine', caps: ['t2v', 'i2v'] },
@ -84,7 +104,7 @@ export const VIDEO_MODELS = [
{ id: 'hyperframes-html', label: 'hyperframes-html', hint: 'HyperFrames · local HTML renderer', provider: 'hyperframes', caps: ['t2v'] },
];
export const AUDIO_MODELS_BY_KIND = {
export const AUDIO_MODELS_BY_KIND: Record<AudioKind, MediaModel[]> = {
music: [
{ id: 'suno-v5', label: 'suno-v5', hint: 'Suno · default', provider: 'suno', caps: ['music'], default: true },
{ id: 'suno-v4-5', label: 'suno-v4.5', hint: 'Suno', provider: 'suno', caps: ['music'] },
@ -108,7 +128,7 @@ export const MEDIA_ASPECTS = ['1:1', '16:9', '9:16', '4:3', '3:4'];
export const VIDEO_LENGTHS_SEC = [3, 5, 8, 10, 15, 30];
export const AUDIO_DURATIONS_SEC = [5, 10, 15, 30, 60, 120];
export function findMediaModel(id) {
export function findMediaModel(id: string): MediaModel | null {
const all = [
...IMAGE_MODELS,
...VIDEO_MODELS,
@ -119,11 +139,11 @@ export function findMediaModel(id) {
return all.find((m) => m.id === id) || null;
}
export function findProvider(id) {
export function findProvider(id: string): MediaProvider | null {
return MEDIA_PROVIDERS.find((p) => p.id === id) || null;
}
export function modelsForSurface(surface, audioKind) {
export function modelsForSurface(surface: MediaSurface, audioKind?: AudioKind): MediaModel[] {
if (surface === 'image') return IMAGE_MODELS;
if (surface === 'video') return VIDEO_MODELS;
if (surface === 'audio') {

View file

@ -1,4 +1,3 @@
// @ts-nocheck
// Media-generation dispatcher. The unifying contract is:
//
// skills + metadata + system-prompt
@ -45,6 +44,10 @@ import { promisify } from 'node:util';
import { Agent as UndiciAgent } from 'undici';
import {
AUDIO_DURATIONS_SEC,
type AudioKind,
type MediaModel,
type MediaProvider,
type MediaSurface,
VIDEO_LENGTHS_SEC,
findMediaModel,
findProvider,
@ -59,6 +62,38 @@ import {
} from './projects.js';
const execFile = promisify(execFileCb);
type ProviderConfig = { apiKey?: string; baseUrl?: string; model?: string };
type ProgressFn = (message: string) => void;
type ImageRef = { path: string; abs: string; mime: string; size: number; dataUrl: string };
type MediaContext = {
surface: MediaSurface;
model: string;
modelDef: MediaModel;
provider: MediaProvider | null;
prompt: string;
aspect: string | undefined;
length: number | undefined;
duration: number | undefined;
voice: string;
audioKind: AudioKind | undefined;
language: string;
compositionDir: string | null;
imageRef: ImageRef | null;
};
type RenderResult = { bytes: Buffer; providerNote: string; suggestedExt?: string };
type JsonRecord = Record<string, unknown>;
function isRecord(value: unknown): value is JsonRecord {
return value !== null && typeof value === 'object';
}
function errorMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
function errorStringProp(err: unknown, key: string): string {
return isRecord(err) && typeof err[key] === 'string' ? err[key] : '';
}
const NANOBANANA_DEFAULT_BASE_URL = 'https://generativelanguage.googleapis.com';
// Verify the current Nano Banana / Gemini image model name against:
// https://ai.google.dev/gemini-api/docs/models
@ -81,13 +116,13 @@ const AUDIO_KINDS = new Set(['music', 'speech', 'sfx']);
// behind OD_MEDIA_ALLOW_STUBS=1 and otherwise return a 503 (mapped from
// the StubProviderDisabledError thrown below) with a clear message.
class StubProviderDisabledError extends Error {
constructor(model) {
code = 'STUB_PROVIDER_DISABLED';
status = 503;
constructor(model: string) {
super(
`provider not configured: ${model}. Add your API key in Settings -> Media Providers to enable real generation.`,
);
this.name = 'StubProviderDisabledError';
this.code = 'STUB_PROVIDER_DISABLED';
this.status = 503;
}
}
@ -105,7 +140,7 @@ function stubsAllowed() {
* Without this guard, an agent (or a hallucinated arg) could ask the
* daemon to upload `/etc/passwd` to a paid model.
*/
async function resolveProjectImage(rel, projectDir) {
async function resolveProjectImage(rel: unknown, projectDir: string): Promise<ImageRef | null> {
if (typeof rel !== 'string' || !rel.trim()) return null;
const projectRootResolved = path.resolve(projectDir);
const abs = path.resolve(projectRootResolved, rel.trim());
@ -161,14 +196,15 @@ async function resolveProjectImage(rel, projectDir) {
};
}
function clampNumber(value, allowed) {
function clampNumber(value: unknown, allowed: number[]): number | undefined {
// Accept exact registry values; otherwise snap to the nearest allowed
// bucket so a hallucinated `Number.MAX_SAFE_INTEGER` can't bill an
// entire month of credits when real providers plug in.
if (typeof value !== 'number' || !Number.isFinite(value)) return undefined;
if (allowed.length === 0) return undefined;
if (allowed.includes(value)) return value;
let best = allowed[0];
let bestDiff = Math.abs(value - allowed[0]);
let best = allowed[0]!;
let bestDiff = Math.abs(value - best);
for (const a of allowed) {
const d = Math.abs(value - a);
if (d < bestDiff) {
@ -179,7 +215,7 @@ function clampNumber(value, allowed) {
return best;
}
function clampWithWarning(value, allowed, flagName) {
function clampWithWarning(value: unknown, allowed: number[], flagName: string): { value: number | undefined; warning: string | null } {
const clamped = clampNumber(value, allowed);
if (
typeof value === 'number'
@ -214,7 +250,11 @@ function clampWithWarning(value, allowed, flagName) {
* @param {string} [args.language]
* @returns {Promise<{ name: string, size: number, mtime: number, kind: string, mime: string, model: string, surface: string, providerNote: string, providerId: string }>}
*/
export async function generateMedia(args) {
export async function generateMedia(args: {
projectRoot: string; projectsRoot: string; projectId: string; surface: MediaSurface; model: string;
prompt?: string; output?: string; aspect?: string; length?: number; duration?: number; voice?: string;
audioKind?: AudioKind; language?: string; compositionDir?: string; image?: string; onProgress?: ProgressFn;
}) {
const {
projectRoot,
projectsRoot,
@ -324,16 +364,16 @@ export async function generateMedia(args) {
const credentials = await resolveProviderConfig(projectRoot, def.provider);
let bytes;
let providerNote;
let suggestedExt;
let bytes: Buffer;
let providerNote: string;
let suggestedExt: string | undefined;
// Tracks whether the bytes came from a real provider call or from the
// stub fallback. Surfaces in the response so the CLI/agent can tell a
// legitimate placeholder ("provider not integrated yet") apart from a
// silent failure ("API call blew up, here's a 67-byte PNG"). Without
// this flag the chat agent narrates the stub as if it's the expected
// output, and the user sees a blank file.
let providerError = null;
let providerError: string | null = null;
let usedStubFallback = false;
// True only when the dispatcher intentionally returned a stub because
// no real renderer is wired up for this (provider, surface) pair.
@ -433,7 +473,7 @@ export async function generateMedia(args) {
}
const stub = await renderStub(ctx, safeOut);
bytes = stub.bytes;
const msg = err && err.message ? err.message : String(err);
const msg = errorMessage(err);
providerNote = `[${def.provider} error → stub] ${msg}`;
providerError = msg;
usedStubFallback = true;
@ -486,7 +526,7 @@ export async function generateMedia(args) {
};
}
function autoOutputName(surface, model, audioKind) {
function autoOutputName(surface: MediaSurface, model: string, audioKind?: AudioKind): string {
const base = DEFAULT_OUTPUT_BY_SURFACE[surface] || 'artifact.bin';
const stamp = Date.now().toString(36);
// Slug the model id so the filename stays short and shell-safe.
@ -498,7 +538,7 @@ function autoOutputName(surface, model, audioKind) {
return `${stem}-${tag}-${stamp}${ext}`;
}
function defaultAspectFor(surface) {
function defaultAspectFor(surface: MediaSurface): string | undefined {
if (surface === 'image') return '1:1';
if (surface === 'video') return '16:9';
return undefined;
@ -527,7 +567,7 @@ const openAIImageDispatcher = new UndiciAgent({
bodyTimeout: OPENAI_IMAGE_BODY_TIMEOUT_MS,
});
async function renderOpenAIImage(ctx, credentials) {
async function renderOpenAIImage(ctx: MediaContext, credentials: ProviderConfig): Promise<RenderResult> {
if (!credentials.apiKey) {
throw new Error('no OpenAI credential — configure an API key in Settings, set OPENAI_API_KEY, or refresh Codex/Hermes OAuth');
}
@ -535,7 +575,7 @@ async function renderOpenAIImage(ctx, credentials) {
const azure = detectAzureEndpoint(rawBase);
const url = buildOpenAIImageUrl(rawBase, azure);
const body = {
const body: Record<string, unknown> = {
prompt: ctx.prompt || 'A high-quality reference image.',
n: 1,
size: openaiSizeFor(ctx.model, ctx.aspect),
@ -556,7 +596,7 @@ async function renderOpenAIImage(ctx, credentials) {
body.quality = 'high';
}
const headers = {
const headers: Record<string, string> = {
'authorization': `Bearer ${credentials.apiKey}`,
'content-type': 'application/json',
};
@ -572,14 +612,14 @@ async function renderOpenAIImage(ctx, credentials) {
method: 'POST',
headers,
body: JSON.stringify(body),
dispatcher: openAIImageDispatcher,
dispatcher: openAIImageDispatcher as unknown as NonNullable<RequestInit['dispatcher']>,
});
const text = await resp.text();
if (!resp.ok) {
const tag = azure ? 'azure-openai' : 'openai';
throw new Error(`${tag} ${resp.status}: ${truncate(text, 240)}`);
}
let data;
let data: any;
try {
data = JSON.parse(text);
} catch {
@ -619,7 +659,7 @@ async function renderOpenAIImage(ctx, credentials) {
* https://api.openai.com/v1
* http://localhost:8080/v1
*/
function detectAzureEndpoint(baseUrl) {
function detectAzureEndpoint(baseUrl: string): boolean {
if (typeof baseUrl !== 'string' || !baseUrl) return false;
if (/\.azure\.com\b/i.test(baseUrl)) return true;
if (/\/openai\/deployments\//i.test(baseUrl)) return true;
@ -632,7 +672,7 @@ function detectAzureEndpoint(baseUrl) {
* appending the default api-version for Azure when the user didn't
* specify one. Returns a string ready for `fetch`.
*/
function buildOpenAIImageUrl(baseUrl, isAzure) {
function buildOpenAIImageUrl(baseUrl: string, isAzure: boolean): string {
let parsed;
try {
parsed = new URL(baseUrl);
@ -649,7 +689,7 @@ function buildOpenAIImageUrl(baseUrl, isAzure) {
return parsed.toString();
}
function openaiSizeFor(model, aspect) {
function openaiSizeFor(model: string, aspect?: string): string {
// gpt-image-1.5 / gpt-image-2 accept arbitrary sizes up to 4096; we
// pick concrete ones tuned to common aspects so the API never
// negotiates them down silently.
@ -683,7 +723,7 @@ const OPENAI_TTS_VOICES = new Set([
'verse',
]);
function buildOpenAISpeechUrl(baseUrl, isAzure) {
function buildOpenAISpeechUrl(baseUrl: string, isAzure: boolean): string {
let parsed;
try {
parsed = new URL(baseUrl);
@ -698,7 +738,7 @@ function buildOpenAISpeechUrl(baseUrl, isAzure) {
return parsed.toString();
}
function openaiSpeechFormatFor(fileName) {
function openaiSpeechFormatFor(fileName: string): string {
const ext = path.extname(fileName).toLowerCase();
if (ext === '.wav') return 'wav';
if (ext === '.flac') return 'flac';
@ -707,7 +747,7 @@ function openaiSpeechFormatFor(fileName) {
return 'mp3';
}
async function renderOpenAISpeech(ctx, credentials, fileName) {
async function renderOpenAISpeech(ctx: MediaContext, credentials: ProviderConfig, fileName: string): Promise<RenderResult> {
if (!credentials.apiKey) {
throw new Error('no OpenAI credential — configure an API key in Settings, set OPENAI_API_KEY, or refresh Codex/Hermes OAuth');
}
@ -731,7 +771,7 @@ async function renderOpenAISpeech(ctx, credentials, fileName) {
}
}
const body = {
const body: Record<string, unknown> = {
input: text,
voice: voiceId,
response_format: format,
@ -743,7 +783,7 @@ async function renderOpenAISpeech(ctx, credentials, fileName) {
body.instructions = instructions;
}
const headers = {
const headers: Record<string, string> = {
authorization: `Bearer ${credentials.apiKey}`,
'content-type': 'application/json',
};
@ -788,7 +828,7 @@ async function renderOpenAISpeech(ctx, credentials, fileName) {
// project folder is required to keep them addressable.
// ---------------------------------------------------------------------------
async function renderVolcengineVideo(ctx, credentials, onProgress) {
async function renderVolcengineVideo(ctx: MediaContext, credentials: ProviderConfig, onProgress?: ProgressFn): Promise<RenderResult> {
if (!credentials.apiKey) {
throw new Error(
'no Volcengine Ark API key — configure it in Settings or set ARK_API_KEY',
@ -803,7 +843,7 @@ async function renderVolcengineVideo(ctx, credentials, onProgress) {
const durationSec = ctx.length || 5;
const resolution = '720p';
const promptText = (ctx.prompt && ctx.prompt.trim()) || 'A short cinematic clip.';
const suffixFlags = [];
const suffixFlags: string[] = [];
if (!/--resolution\b/.test(promptText)) suffixFlags.push(`--resolution ${resolution}`);
if (!/--duration\b/.test(promptText)) suffixFlags.push(`--duration ${durationSec}`);
if (!/--ratio\b/.test(promptText)) suffixFlags.push(`--ratio ${ratio}`);
@ -816,7 +856,7 @@ async function renderVolcengineVideo(ctx, credentials, onProgress) {
// it as the first frame and animates from there. We pass the data
// URL directly; the API does not require a public URL. When no
// image is provided, this is a regular t2v call.
const content = [{ type: 'text', text: fullText }];
const content: Array<Record<string, unknown>> = [{ type: 'text', text: fullText }];
if (ctx.imageRef && ctx.imageRef.dataUrl) {
content.push({
type: 'image_url',
@ -841,7 +881,7 @@ async function renderVolcengineVideo(ctx, credentials, onProgress) {
if (!taskResp.ok) {
throw new Error(`volcengine task create ${taskResp.status}: ${truncate(taskText, 240)}`);
}
let taskData;
let taskData: any;
try {
taskData = JSON.parse(taskText);
} catch {
@ -859,7 +899,7 @@ async function renderVolcengineVideo(ctx, credentials, onProgress) {
Number.isFinite(configuredMaxMs) && configuredMaxMs >= 60_000
? configuredMaxMs
: 12 * 60 * 1000;
let videoUrl = null;
let videoUrl: string | null = null;
let lastStatus = '';
// Emit a "task accepted" line right away so the agent's chat shows
// something within the first second instead of going silent for the
@ -880,7 +920,7 @@ async function renderVolcengineVideo(ctx, credentials, onProgress) {
if (!pollResp.ok) {
throw new Error(`volcengine poll ${pollResp.status}: ${truncate(pollText, 240)}`);
}
let pollData;
let pollData: any;
try {
pollData = JSON.parse(pollText);
} catch {
@ -920,7 +960,7 @@ async function renderVolcengineVideo(ctx, credentials, onProgress) {
};
}
function volcengineRatioFor(aspect) {
function volcengineRatioFor(aspect?: string): string {
// Seedance accepts a fixed list of ratios; map the OD vocabulary to
// its canonical strings.
if (!aspect) return '16:9';
@ -932,7 +972,7 @@ function volcengineRatioFor(aspect) {
// Volcengine Seedream / Seededit images. Same auth, different endpoint:
// POST /api/v3/images/generations (OpenAI-compatible payload).
async function renderVolcengineImage(ctx, credentials) {
async function renderVolcengineImage(ctx: MediaContext, credentials: ProviderConfig): Promise<RenderResult> {
if (!credentials.apiKey) {
throw new Error('no Volcengine Ark API key — configure it in Settings or set ARK_API_KEY');
}
@ -956,7 +996,7 @@ async function renderVolcengineImage(ctx, credentials) {
if (!resp.ok) {
throw new Error(`volcengine image ${resp.status}: ${truncate(text, 240)}`);
}
let data;
let data: any;
try {
data = JSON.parse(text);
} catch {
@ -999,7 +1039,7 @@ async function renderVolcengineImage(ctx, credentials) {
// declares the `audio` capability.
// ---------------------------------------------------------------------------
async function renderGrokImage(ctx, credentials) {
async function renderGrokImage(ctx: MediaContext, credentials: ProviderConfig): Promise<RenderResult> {
if (!credentials.apiKey) {
throw new Error(
'no xAI API key — configure it in Settings or set XAI_API_KEY',
@ -1027,7 +1067,7 @@ async function renderGrokImage(ctx, credentials) {
if (!resp.ok) {
throw new Error(`grok image ${resp.status}: ${truncate(text, 240)}`);
}
let data;
let data: any;
try {
data = JSON.parse(text);
} catch {
@ -1057,7 +1097,7 @@ async function renderGrokImage(ctx, credentials) {
};
}
async function renderNanoBananaImage(ctx, credentials) {
async function renderNanoBananaImage(ctx: MediaContext, credentials: ProviderConfig): Promise<RenderResult> {
if (!credentials.apiKey) {
throw new Error(
'no Nano Banana API key — configure it in Settings or set OD_NANOBANANA_API_KEY',
@ -1090,7 +1130,7 @@ async function renderNanoBananaImage(ctx, credentials) {
if (!resp.ok) {
throw new Error(`nano-banana image ${resp.status}: ${truncate(text, 240)}`);
}
let data;
let data: any;
try {
data = JSON.parse(text);
} catch {
@ -1104,8 +1144,8 @@ async function renderNanoBananaImage(ctx, credentials) {
};
}
function nanoBananaHeaders(baseUrl, apiKey) {
const headers = {
function nanoBananaHeaders(baseUrl: string, apiKey: string): Record<string, string> {
const headers: Record<string, string> = {
'content-type': 'application/json',
};
if (usesOfficialGoogleApiKeyHeader(baseUrl)) {
@ -1116,7 +1156,7 @@ function nanoBananaHeaders(baseUrl, apiKey) {
return headers;
}
function usesOfficialGoogleApiKeyHeader(baseUrl) {
function usesOfficialGoogleApiKeyHeader(baseUrl: string): boolean {
try {
const url = new URL(baseUrl);
return url.hostname === 'generativelanguage.googleapis.com';
@ -1125,7 +1165,7 @@ function usesOfficialGoogleApiKeyHeader(baseUrl) {
}
}
function nanoBananaAspectFor(aspect) {
function nanoBananaAspectFor(aspect?: string): string {
if (
aspect === '1:1'
|| aspect === '16:9'
@ -1138,7 +1178,7 @@ function nanoBananaAspectFor(aspect) {
return '1:1';
}
function inlineImageBytesFromGenerateContent(data) {
function inlineImageBytesFromGenerateContent(data: any): Buffer {
const candidates = Array.isArray(data?.candidates) ? data.candidates : [];
for (const candidate of candidates) {
const parts = Array.isArray(candidate?.content?.parts) ? candidate.content.parts : [];
@ -1152,7 +1192,7 @@ function inlineImageBytesFromGenerateContent(data) {
throw new Error('nano-banana image response missing candidates[].content.parts[].inlineData.data');
}
function sniffImageExt(bytes) {
function sniffImageExt(bytes: Buffer): string {
if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) {
return '.jpg';
}
@ -1172,7 +1212,7 @@ function sniffImageExt(bytes) {
return '.png';
}
async function renderGrokVideo(ctx, credentials, onProgress) {
async function renderGrokVideo(ctx: MediaContext, credentials: ProviderConfig, onProgress?: ProgressFn): Promise<RenderResult> {
if (!credentials.apiKey) {
throw new Error(
'no xAI API key — configure it in Settings or set XAI_API_KEY',
@ -1187,7 +1227,7 @@ async function renderGrokVideo(ctx, credentials, onProgress) {
const durationSec = Math.min(Math.max(requested, 1), 15);
const aspectRatio = grokAspectFor(ctx.aspect);
const body = {
const body: Record<string, unknown> = {
model: ctx.model,
prompt: ctx.prompt || 'A short cinematic clip.',
duration: durationSec,
@ -1213,7 +1253,7 @@ async function renderGrokVideo(ctx, credentials, onProgress) {
if (!submitResp.ok) {
throw new Error(`grok video submit ${submitResp.status}: ${truncate(submitText, 240)}`);
}
let submitData;
let submitData: any;
try {
submitData = JSON.parse(submitText);
} catch {
@ -1248,7 +1288,7 @@ async function renderGrokVideo(ctx, credentials, onProgress) {
if (!pollResp.ok) {
throw new Error(`grok poll ${pollResp.status}: ${truncate(pollText, 240)}`);
}
let pollData;
let pollData: any;
try {
pollData = JSON.parse(pollText);
} catch {
@ -1307,7 +1347,7 @@ async function renderGrokVideo(ctx, credentials, onProgress) {
};
}
function grokAspectFor(aspect) {
function grokAspectFor(aspect?: string): string {
// xAI accepts a wide list (1:1, 16:9, 9:16, 4:3, 3:4, 3:2, 2:3, 2:1,
// 1:2, 19.5:9, 9:19.5, 20:9, 9:20, auto). Our MEDIA_ASPECTS subset
// is a strict subset — pass through known values, otherwise 16:9.
@ -1343,9 +1383,9 @@ const MINIMAX_DEFAULT_BASE_URL = 'https://api.minimaxi.chat/v1';
// internal naming.
const MINIMAX_TTS_MODEL_MAP = {
'minimax-tts': 'speech-02-turbo',
};
} as Record<string, string>;
async function renderMinimaxTTS(ctx, credentials) {
async function renderMinimaxTTS(ctx: MediaContext, credentials: ProviderConfig): Promise<RenderResult> {
if (!credentials.apiKey) {
throw new Error(
'no MiniMax API key — configure it in Settings or set OD_MINIMAX_API_KEY',
@ -1395,7 +1435,7 @@ async function renderMinimaxTTS(ctx, credentials) {
if (!resp.ok) {
throw new Error(`minimax tts ${resp.status}: ${truncate(respText, 240)}`);
}
let data;
let data: any;
try {
data = JSON.parse(respText);
} catch {
@ -1446,9 +1486,9 @@ const FISHAUDIO_DEFAULT_BASE_URL = 'https://api.fish.audio';
const FISHAUDIO_TTS_MODEL_MAP = {
'fish-speech-2': 'speech-1.6',
};
} as Record<string, string>;
async function renderFishAudioTTS(ctx, credentials) {
async function renderFishAudioTTS(ctx: MediaContext, credentials: ProviderConfig): Promise<RenderResult> {
if (!credentials.apiKey) {
throw new Error(
'no FishAudio API key — configure it in Settings or set OD_FISHAUDIO_API_KEY',
@ -1464,7 +1504,7 @@ async function renderFishAudioTTS(ctx, credentials) {
// FishAudio's `reference_id` slot pins which voice the synth uses.
// The agent passes it via --voice (carried in ctx.voice). Empty means
// FishAudio falls back to its default voice for the chosen model.
const body = {
const body: Record<string, unknown> = {
text,
format: 'mp3',
mp3_bitrate: 128,
@ -1523,7 +1563,7 @@ async function renderFishAudioTTS(ctx, credentials) {
const HYPERFRAMES_RENDER_TIMEOUT_MS = 5 * 60 * 1000;
async function renderHyperFramesViaCli(ctx, projectDir, onProgress) {
async function renderHyperFramesViaCli(ctx: MediaContext, projectDir: string, onProgress?: ProgressFn): Promise<RenderResult> {
const compRel = ctx.compositionDir;
if (typeof compRel !== 'string' || !compRel.trim()) {
throw new Error(
@ -1588,8 +1628,8 @@ async function renderHyperFramesViaCli(ctx, projectDir, onProgress) {
};
} catch (err) {
const stderr =
err && typeof err.stderr === 'string' ? err.stderr.trim() : '';
const message = stderr || (err && err.message ? err.message : String(err));
errorStringProp(err, 'stderr').trim();
const message = stderr || errorMessage(err);
throw new Error(`hyperframes render failed: ${truncate(message, 480)}`);
} finally {
await rm(tmpRoot, { recursive: true, force: true });
@ -1607,8 +1647,8 @@ async function renderHyperFramesViaCli(ctx, projectDir, onProgress) {
* agent's chat tool shows a long quiet spinner — users can't tell
* whether anything is happening.
*/
function runHyperFramesRender(compAbs, tmpOutput, onProgress) {
return new Promise((resolve, reject) => {
function runHyperFramesRender(compAbs: string, tmpOutput: string, onProgress?: ProgressFn): Promise<void> {
return new Promise<void>((resolve, reject) => {
const child = spawn(
'npx',
[
@ -1634,10 +1674,10 @@ function runHyperFramesRender(compAbs, tmpOutput, onProgress) {
// erases) for its pretty progress bar. Strip those before
// forwarding so the agent's chat doesn't render a wall of `[2K`.
// The regex covers CSI sequences (most of what HF emits).
const stripAnsi = (s) =>
const stripAnsi = (s: string): string =>
s.replace(/\x1b\[[0-9;?]*[A-Za-z]/g, '').replace(/\x1b\[\?[0-9]+[hl]/g, '');
const emit = (chunk) => {
const emit = (chunk: Buffer): void => {
if (typeof onProgress !== 'function') return;
const text = stripAnsi(chunk.toString('utf8'));
// HF refreshes a single progress line many times per second; split
@ -1688,7 +1728,7 @@ function runHyperFramesRender(compAbs, tmpOutput, onProgress) {
const tail = stderrTail.trim().split('\n').slice(-12).join('\n');
const err = new Error(
`hyperframes render exited ${reason}` + (tail ? `\n${tail}` : ''),
);
) as Error & { stderr: string };
err.stderr = tail;
reject(err);
});
@ -1703,7 +1743,7 @@ function runHyperFramesRender(compAbs, tmpOutput, onProgress) {
// downstream FileViewer round-trip works while the backend matures.
// ---------------------------------------------------------------------------
async function renderStub(ctx, fileName) {
async function renderStub(ctx: MediaContext, fileName: string): Promise<RenderResult> {
const note = ctx.provider && !ctx.provider.integrated
? `stub-${ctx.surface} · provider '${ctx.provider.id}' integration pending`
: `stub-${ctx.surface} · model=${ctx.model}`;
@ -1755,9 +1795,9 @@ async function renderStub(ctx, fileName) {
};
}
function svgPlaceholder(ctx) {
function svgPlaceholder(ctx: MediaContext): string {
const [w, h] = aspectToBox(ctx.aspect, 800);
const safe = (s) =>
const safe = (s: unknown): string =>
String(s || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
@ -1770,14 +1810,14 @@ function svgPlaceholder(ctx) {
].join('');
}
function aspectToBox(aspect, base) {
function aspectToBox(aspect: string | undefined, base: number): [number, number] {
const [a, b] = String(aspect || '1:1').split(':').map(Number);
if (!a || !b) return [base, base];
if (a >= b) return [base, Math.round((base * b) / a)];
return [Math.round((base * a) / b), base];
}
function silentWav(seconds) {
function silentWav(seconds: number): Buffer {
const sampleRate = 8000;
const numSamples = Math.max(1, Math.round(sampleRate * seconds));
const dataSize = numSamples * 2;
@ -1798,12 +1838,12 @@ function silentWav(seconds) {
return buf;
}
function truncate(s, n) {
function truncate(s: unknown, n: number): string {
const v = String(s || '');
if (v.length <= n) return v;
return v.slice(0, n - 1) + '…';
}
function sleep(ms) {
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}

View file

@ -1,4 +1,3 @@
// @ts-nocheck
/**
* Drives pi's `--mode rpc` JSON-RPC protocol over stdio and maps agent
* events into the daemon's typed UI events (the same set that
@ -19,8 +18,66 @@
import fs from 'node:fs';
import path from 'node:path';
import type { ChildProcess } from 'node:child_process';
import type { Writable } from 'node:stream';
import { createJsonLineStream } from './acp.js';
type JsonRecord = Record<string, unknown>;
type SendAgentEvent = (channel: string, payload: JsonRecord) => void;
type TokenUsage = {
input_tokens?: number;
output_tokens?: number;
cached_read_tokens?: number;
cached_write_tokens?: number;
total_tokens?: number;
};
type PiImagePayload = {
type: 'image';
data: string;
mimeType: string;
};
type PiRpcParams = JsonRecord;
type PiRpcSessionOptions = {
child: ChildProcess;
prompt: string;
cwd?: string;
model?: string | null;
send: SendAgentEvent;
imagePaths?: string[];
uploadRoot?: string;
};
type PiRpcSession = {
hasFatalError(): boolean;
abort(): void;
};
type PiRpcContext = {
runStartedAt: number;
sentFirstToken: { value: boolean };
};
function isRecord(value: unknown): value is JsonRecord {
return typeof value === 'object' && value !== null;
}
function errorMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
function errorCode(err: unknown): string | undefined {
return isRecord(err) && typeof err.code === 'string' ? err.code : undefined;
}
function getRecord(value: unknown): JsonRecord | undefined {
return isRecord(value) ? value : undefined;
}
// Image forwarding budgets to prevent large synchronous base64 work.
const MAX_IMAGE_COUNT = 10;
const MAX_TOTAL_IMAGE_BYTES = 20 * 1024 * 1024; // 20 MB
@ -41,11 +98,11 @@ const FIRE_AND_FORGET_METHODS = new Set([
'set_editor_text',
]);
function replyExtensionUi(writable, raw) {
function replyExtensionUi(writable: Writable, raw: JsonRecord): void {
if (raw?.id == null) return;
// Fire-and-forget: no response expected. Silently consume.
if (FIRE_AND_FORGET_METHODS.has(raw.method)) return;
if (typeof raw.method === 'string' && FIRE_AND_FORGET_METHODS.has(raw.method)) return;
// Dialog methods: auto-resolve to keep pi unblocked.
// confirm → true, select/input/editor → empty-ish default
@ -54,13 +111,14 @@ function replyExtensionUi(writable, raw) {
result = { confirmed: true };
} else {
// select: pick first option if available, else cancel
const opts = raw.params?.options ?? raw.options;
const params = getRecord(raw.params);
const opts = params?.options ?? raw.options;
if (Array.isArray(opts) && opts.length > 0) {
const first = opts[0];
result =
typeof first === 'string'
? { value: first }
: { value: first?.label ?? first?.value ?? '' };
: { value: getRecord(first)?.label ?? getRecord(first)?.value ?? '' };
} else {
result = { cancelled: true };
}
@ -84,7 +142,11 @@ function replyExtensionUi(writable, raw) {
* @param {{ value: boolean }} ctx.sentFirstToken - mutable flag
* @returns {string|null} 'agent_end' if the agent is done, null otherwise
*/
export function mapPiRpcEvent(raw, send, ctx) {
export function mapPiRpcEvent(
raw: JsonRecord,
send: SendAgentEvent,
ctx: PiRpcContext,
): 'agent_end' | null {
if (raw.type === 'agent_start') {
send('agent', { type: 'status', label: 'working' });
return null;
@ -100,16 +162,18 @@ export function mapPiRpcEvent(raw, send, ctx) {
}
if (raw.type === 'turn_end') {
if (raw.message?.usage) {
const u = raw.message.usage;
const usage = {};
const message = getRecord(raw.message);
const messageUsage = getRecord(message?.usage);
if (messageUsage) {
const u = messageUsage;
const usage: TokenUsage = {};
if (typeof u.input === 'number') usage.input_tokens = u.input;
if (typeof u.output === 'number') usage.output_tokens = u.output;
if (typeof u.cacheRead === 'number') usage.cached_read_tokens = u.cacheRead;
if (typeof u.cacheWrite === 'number') usage.cached_write_tokens = u.cacheWrite;
if (typeof u.totalTokens === 'number') usage.total_tokens = u.totalTokens;
if (Object.keys(usage).length > 0) {
const cost = u.cost;
const cost = getRecord(u.cost);
send('agent', {
type: 'usage',
usage,
@ -121,8 +185,9 @@ export function mapPiRpcEvent(raw, send, ctx) {
return null;
}
if (raw.type === 'message_update' && raw.assistantMessageEvent) {
const ev = raw.assistantMessageEvent;
const assistantMessageEvent = getRecord(raw.assistantMessageEvent);
if (raw.type === 'message_update' && assistantMessageEvent) {
const ev = assistantMessageEvent;
if (ev.type === 'text_delta' && typeof ev.delta === 'string') {
if (!ctx.sentFirstToken.value) {
@ -188,11 +253,15 @@ export function mapPiRpcEvent(raw, send, ctx) {
}
if (raw.type === 'tool_execution_end') {
const content = raw.result?.content;
const result = getRecord(raw.result);
const content = result?.content;
const text =
Array.isArray(content)
? content
.map((c) => (c?.type === 'text' ? c.text : JSON.stringify(c)))
.map((c: unknown) => {
const item = getRecord(c);
return item?.type === 'text' ? String(item.text ?? '') : JSON.stringify(c);
})
.join('\n')
: typeof content === 'string'
? content
@ -265,7 +334,24 @@ export function mapPiRpcEvent(raw, send, ctx) {
* @param {function} opts.send - SSE send function
* @returns {{ hasFatalError(): boolean, abort(): void }}
*/
export function attachPiRpcSession({ child, prompt, cwd, model, send, imagePaths, uploadRoot }) {
export function attachPiRpcSession({
child,
prompt,
cwd: _cwd,
model,
send,
imagePaths,
uploadRoot,
}: PiRpcSessionOptions): PiRpcSession {
const stdin = child.stdin;
const stdout = child.stdout;
if (stdin === null) {
throw new Error('pi RPC child process is missing stdin');
}
if (stdout === null) {
throw new Error('pi RPC child process is missing stdout');
}
const runStartedAt = Date.now();
let finished = false;
let fatal = false;
@ -274,21 +360,17 @@ export function attachPiRpcSession({ child, prompt, cwd, model, send, imagePaths
let nextRpcId = 1;
let stdinOpen = true;
function sendCommand(writable, type, params = {}) {
function sendCommand(writable: Writable, type: string, params: PiRpcParams = {}): number | null {
if (!stdinOpen) return null;
const id = nextRpcId++;
try {
writable.write(`${JSON.stringify({ id, type, ...params })}\n`);
return id;
} catch {
return null;
}
writable.write(`${JSON.stringify({ id, type, ...params })}\n`);
return id;
}
// Track the prompt request id so we know when the prompt response arrives.
let promptRpcId = null;
let promptRpcId: number | null = null;
const fail = (message) => {
const fail = (message: string): void => {
if (finished) return;
finished = true;
fatal = true;
@ -305,12 +387,12 @@ export function attachPiRpcSession({ child, prompt, cwd, model, send, imagePaths
});
// ---- Outbound: send the prompt via RPC ----
child.stdin.on('error', (err) => {
if (err.code !== 'EPIPE') {
fail(`stdin: ${err.message}`);
stdin.on('error', (err: unknown) => {
if (errorCode(err) !== 'EPIPE') {
fail(`stdin: ${errorMessage(err)}`);
}
});
child.stdin.on('close', () => {
stdin.on('close', () => {
stdinOpen = false;
});
@ -323,7 +405,7 @@ export function attachPiRpcSession({ child, prompt, cwd, model, send, imagePaths
// path is still a regular file (no /proc/self/mem or symlink escape).
// We also enforce a count and total-byte budget to prevent large
// synchronous base64 reads from blocking the event loop.
const images = [];
const images: PiImagePayload[] = [];
if (Array.isArray(imagePaths) && imagePaths.length > 0) {
let totalBytes = 0;
for (const imgPath of imagePaths) {
@ -362,19 +444,20 @@ export function attachPiRpcSession({ child, prompt, cwd, model, send, imagePaths
mimeType,
});
totalBytes += stat.size;
} catch {
} catch (_err: unknown) {
// Skip unreadable images rather than failing the entire run.
}
}
}
promptRpcId = sendCommand(child.stdin, 'prompt', {
promptRpcId = sendCommand(stdin, 'prompt', {
message: prompt,
...(images.length > 0 ? { images } : {}),
});
// ---- Inbound: parse stdout events ----
const parser = createJsonLineStream((raw) => {
const parser = createJsonLineStream((raw: unknown) => {
if (!isRecord(raw)) return;
// Once finished (agent_end or abort), stop processing — the run is
// over, so no more agent events should be emitted. We still drain
// stdout via parser.feed() so the pipe doesn't break; we just skip
@ -383,7 +466,7 @@ export function attachPiRpcSession({ child, prompt, cwd, model, send, imagePaths
// Extension UI requests: auto-resolve to keep pi unblocked.
if (raw.type === 'extension_ui_request') {
replyExtensionUi(child.stdin, raw);
replyExtensionUi(stdin, raw);
return;
}
@ -391,7 +474,7 @@ export function attachPiRpcSession({ child, prompt, cwd, model, send, imagePaths
// agent events. Log the prompt acceptance, ignore the rest.
if (raw.type === 'response') {
if (raw.id === promptRpcId && raw.success === false) {
fail(`prompt rejected: ${raw.error ?? 'unknown'}`);
fail(`prompt rejected: ${String(raw.error ?? 'unknown')}`);
}
return;
}
@ -406,8 +489,10 @@ export function attachPiRpcSession({ child, prompt, cwd, model, send, imagePaths
// so close stdin and let the process exit naturally, or kill it
// after a grace period.
try {
child.stdin.end();
} catch {}
stdin.end();
} catch (err: unknown) {
fail(`stdin close: ${errorMessage(err)}`);
}
// Grace period before SIGTERM. Configurable via PI_GRACEFUL_SHUTDOWN_MS
// for resource-constrained machines where the event loop drains slowly.
const shutdownMs = Number(process.env.PI_GRACEFUL_SHUTDOWN_MS) || 5000;
@ -417,15 +502,15 @@ export function attachPiRpcSession({ child, prompt, cwd, model, send, imagePaths
}
});
child.stdout.on('data', (chunk) => {
stdout.on('data', (chunk: Buffer | string) => {
try {
parser.feed(chunk);
parser.feed(typeof chunk === 'string' ? chunk : chunk.toString('utf8'));
} catch (err) {
fail(`parser: ${err.message}`);
fail(`parser: ${errorMessage(err)}`);
}
});
child.stdout.on('close', () => parser.flush());
child.on('error', (err) => fail(err.message));
stdout.on('close', () => parser.flush());
child.on('error', (err: unknown) => fail(errorMessage(err)));
return {
hasFatalError() {
@ -438,7 +523,7 @@ export function attachPiRpcSession({ child, prompt, cwd, model, send, imagePaths
// not by this method.
if (finished || child.killed) return;
finished = true;
sendCommand(child.stdin, 'abort');
sendCommand(stdin, 'abort');
},
};
}
@ -453,7 +538,9 @@ export function attachPiRpcSession({ child, prompt, cwd, model, send, imagePaths
*
* We collapse to `provider/model` ids and prepend the synthetic default.
*/
export function parsePiModels(stdout) {
type PiModelOption = { id: string; label: string };
export function parsePiModels(stdout: unknown): PiModelOption[] | null {
const lines = String(stdout || '')
.split('\n')
.map((l) => l.trim())
@ -467,10 +554,13 @@ export function parsePiModels(stdout) {
const entries = [DEFAULT_MODEL_OPTION];
const seen = new Set(['default']);
for (let i = 1; i < lines.length; i++) {
const parts = lines[i].split(/\s+/);
const line = lines[i];
if (line === undefined) continue;
const parts = line.split(/\s+/);
if (parts.length < 2) continue;
const provider = parts[0];
const modelId = parts[1];
if (provider === undefined || modelId === undefined) continue;
// Skip duplicates (some providers list the same model under multiple names).
const fullId = `${provider}/${modelId}`;
if (seen.has(fullId)) continue;

View file

@ -1,6 +1,5 @@
// @ts-nocheck
import path from 'node:path';
import chokidar from 'chokidar';
import chokidar, { type FSWatcher } from 'chokidar';
import { projectDir, resolveProjectDir } from './projects.js';
@ -35,8 +34,26 @@ const IGNORE_NAMES = new Set([
'.tox',
'.ruff_cache',
]);
export function makeIgnored(rootDir) {
return (absPath) => {
export type ProjectWatchKind = 'add' | 'change' | 'unlink';
export interface ProjectWatchEvent { type: 'file-changed'; path: string; kind: ProjectWatchKind }
export type ProjectWatchCallback = (evt: ProjectWatchEvent) => void;
export interface ProjectWatcherOptions {
ignored?: (absPath: string) => boolean;
awaitWriteFinish?: false | { stabilityThreshold: number; pollInterval: number };
metadata?: unknown;
_watcherFactory?: WatcherFactory;
}
interface WatcherEntry {
dir: string;
watcher: FSWatcher;
ready: Promise<void>;
subscribers: Set<ProjectWatchCallback>;
closing: Promise<void> | null;
}
type WatcherFactory = (dir: string, opts: Required<Pick<ProjectWatcherOptions, 'ignored' | 'awaitWriteFinish'>>) => WatcherEntry;
export function makeIgnored(rootDir: string): (absPath: string) => boolean {
return (absPath: string): boolean => {
const rel = path.relative(rootDir, absPath);
if (!rel || rel === '' || rel.startsWith('..')) return false; // never ignore root itself
return rel.split(/[\\/]/).some((seg) => IGNORE_NAMES.has(seg));
@ -48,9 +65,9 @@ export const DEFAULT_AWAIT_WRITE_FINISH = {
pollInterval: 50,
};
const registry = new Map();
const registry = new Map<string, WatcherEntry>();
function makeEntry(dir, opts) {
function makeEntry(dir: string, opts: Required<Pick<ProjectWatcherOptions, 'ignored' | 'awaitWriteFinish'>>): WatcherEntry {
const watcher = chokidar.watch(dir, {
ignored: opts.ignored,
ignoreInitial: true,
@ -73,11 +90,11 @@ function makeEntry(dir, opts) {
}
});
let resolveReady;
const ready = new Promise((r) => { resolveReady = r; });
let resolveReady: () => void;
const ready = new Promise<void>((resolve) => { resolveReady = resolve; });
watcher.once('ready', () => resolveReady());
const entry = {
const entry: WatcherEntry = {
dir,
watcher,
ready,
@ -85,10 +102,10 @@ function makeEntry(dir, opts) {
closing: null,
};
const broadcast = (kind) => (absPath) => {
const broadcast = (kind: ProjectWatchKind) => (absPath: string) => {
const rel = path.relative(dir, absPath);
if (!rel || rel.startsWith('..')) return;
const evt = { type: 'file-changed', path: rel.split(path.sep).join('/'), kind };
const evt: ProjectWatchEvent = { type: 'file-changed', path: rel.split(path.sep).join('/'), kind };
for (const cb of entry.subscribers) {
try {
cb(evt);
@ -120,7 +137,7 @@ function makeEntry(dir, opts) {
* `unsubscribe` releases the subscriber and closes the watcher if it was the
* last; `ready` resolves once chokidar has finished its initial scan.
*/
export function subscribe(projectsRoot, projectId, onEvent, opts = {}) {
export function subscribe(projectsRoot: string, projectId: string, onEvent: ProjectWatchCallback, opts: ProjectWatcherOptions = {}) {
// Resolve to the project's actual root: for folder-imported projects
// (metadata.baseDir set) we watch the user's folder so the live-reload
// SSE stream actually fires when their files change. The registry is
@ -158,19 +175,19 @@ export function subscribe(projectsRoot, projectId, onEvent, opts = {}) {
}
/** Test-only: drop all watchers. */
export async function _resetForTests() {
export async function _resetForTests(): Promise<void> {
const entries = Array.from(registry.values());
registry.clear();
await Promise.allSettled(entries.map((e) => e.watcher.close()));
}
/** Test-only: number of active watchers. */
export function _activeWatcherCount() {
export function _activeWatcherCount(): number {
return registry.size;
}
/** Test-only: return the chokidar FSWatcher for a given project's directory. */
export function _internalWatcherForTests(projectsRoot, projectId) {
export function _internalWatcherForTests(projectsRoot: string, projectId: string): FSWatcher | undefined {
const dir = projectDir(projectsRoot, projectId);
return registry.get(dir)?.watcher;
}

View file

@ -553,8 +553,20 @@ function toProjectPath(raw) {
return raw.split(path.sep).join('/');
}
function isSafeId(id) {
return typeof id === 'string' && /^[A-Za-z0-9._-]{1,128}$/.test(id);
// Validates an id string for use as a path segment under a daemon-managed
// directory (`.od/projects/<id>`, `design-systems/<id>`, etc.). The character
// class allows dots so ids like `my-project.v2` work, but pure-dot ids
// (`.`, `..`, `...`) MUST be rejected — they pass the char-class check but
// resolve to the parent directory when fed into `path.join`. Without the
// pure-dot guard, an attacker could create a project row with id `..` (or
// reach this code via a percent-encoded URL like `/api/projects/%2e%2e/...`
// which Express decodes before the route handler sees it) and steer
// finalize / write operations outside `.od/projects/`.
export function isSafeId(id) {
if (typeof id !== 'string') return false;
if (id.length === 0 || id.length > 128) return false;
if (/^\.+$/.test(id)) return false; // reject `.`, `..`, `...`, etc.
return /^[A-Za-z0-9._-]+$/.test(id);
}
const EXT_MIME = {
@ -566,6 +578,7 @@ const EXT_MIME = {
'.cjs': 'text/javascript; charset=utf-8',
'.jsx': 'text/javascript; charset=utf-8',
'.ts': 'text/typescript; charset=utf-8',
'.py': 'text/x-python; charset=utf-8',
// `.tsx` previously served as `text/typescript`, which browser module
// loaders and strict CSPs do not accept as a JavaScript MIME. Multi-file
// React prototypes that load `.tsx` via Babel-standalone (`<script

View file

@ -1,4 +1,3 @@
// @ts-nocheck
// Prompt template registry. Mirrors design-systems.js: scans
// <projectRoot>/prompt-templates/{image,video}/*.json on every list call
// and returns the parsed entries with light validation.
@ -11,10 +10,31 @@
import { readdir, readFile, stat } from 'node:fs/promises';
import path from 'node:path';
const SUPPORTED_SURFACES = ['image', 'video'];
const SUPPORTED_SURFACES = ['image', 'video'] as const;
type PromptTemplateSurface = (typeof SUPPORTED_SURFACES)[number];
type JsonRecord = Record<string, unknown>;
export async function listPromptTemplates(root) {
const out = [];
interface PromptTemplate {
id: string;
surface: PromptTemplateSurface;
title: string;
summary: string;
category: string;
tags: string[];
model?: string;
aspect?: string;
prompt: string;
previewImageUrl?: string;
previewVideoUrl?: string;
source: { repo: string; license: string; author?: string; url?: string };
}
function isRecord(value: unknown): value is JsonRecord {
return Boolean(value) && typeof value === 'object';
}
export async function listPromptTemplates(root: string): Promise<PromptTemplate[]> {
const out: PromptTemplate[] = [];
for (const surface of SUPPORTED_SURFACES) {
const dir = path.join(root, surface);
let entries = [];
@ -50,8 +70,8 @@ export async function listPromptTemplates(root) {
return out;
}
export async function readPromptTemplate(root, surface, id) {
if (!SUPPORTED_SURFACES.includes(surface)) return null;
export async function readPromptTemplate(root: string, surface: string, id: string): Promise<PromptTemplate | null> {
if (!isPromptTemplateSurface(surface)) return null;
const filePath = path.join(root, surface, `${id}.json`);
try {
const raw = await readFile(filePath, 'utf8');
@ -62,8 +82,12 @@ export async function readPromptTemplate(root, surface, id) {
}
}
function validateTemplate(raw, expectedSurface, fileName) {
if (!raw || typeof raw !== 'object') return null;
function isPromptTemplateSurface(surface: string): surface is PromptTemplateSurface {
return (SUPPORTED_SURFACES as readonly string[]).includes(surface);
}
function validateTemplate(raw: unknown, expectedSurface: PromptTemplateSurface, fileName: string): PromptTemplate | null {
if (!isRecord(raw)) return null;
if (typeof raw.id !== 'string' || !raw.id) {
console.warn(`prompt-templates: ${fileName} missing id`);
return null;
@ -79,30 +103,29 @@ function validateTemplate(raw, expectedSurface, fileName) {
console.warn(`prompt-templates: ${fileName} prompt too short`);
return null;
}
const source = raw.source && typeof raw.source === 'object' ? raw.source : null;
const source = isRecord(raw.source) ? raw.source : null;
if (!source || typeof source.repo !== 'string' || typeof source.license !== 'string') {
console.warn(`prompt-templates: ${fileName} missing source.repo / license`);
return null;
}
return {
const template: PromptTemplate = {
id: raw.id,
surface: raw.surface,
surface: expectedSurface,
title: raw.title.trim(),
summary: typeof raw.summary === 'string' ? raw.summary.trim() : '',
category: typeof raw.category === 'string' ? raw.category : 'General',
tags: Array.isArray(raw.tags) ? raw.tags.filter((t) => typeof t === 'string') : [],
model: typeof raw.model === 'string' ? raw.model : undefined,
aspect: typeof raw.aspect === 'string' ? raw.aspect : undefined,
tags: Array.isArray(raw.tags) ? raw.tags.filter((t): t is string => typeof t === 'string') : [],
prompt: raw.prompt.trim(),
previewImageUrl:
typeof raw.previewImageUrl === 'string' ? raw.previewImageUrl : undefined,
previewVideoUrl:
typeof raw.previewVideoUrl === 'string' ? raw.previewVideoUrl : undefined,
source: {
repo: source.repo,
license: source.license,
author: typeof source.author === 'string' ? source.author : undefined,
url: typeof source.url === 'string' ? source.url : undefined,
},
};
if (typeof raw.model === 'string') template.model = raw.model;
if (typeof raw.aspect === 'string') template.aspect = raw.aspect;
if (typeof raw.previewImageUrl === 'string') template.previewImageUrl = raw.previewImageUrl;
if (typeof raw.previewVideoUrl === 'string') template.previewVideoUrl = raw.previewVideoUrl;
if (typeof source.author === 'string') template.source.author = source.author;
if (typeof source.url === 'string') template.source.url = source.url;
return template;
}

View file

@ -1,4 +1,3 @@
// @ts-nocheck
/**
* Parses Qoder CLI's `--output-format stream-json` JSONL stream into the
* small event set consumed by the chat UI. Qoder's top-level records are
@ -6,8 +5,19 @@
* fields, so keep this parser separate from Claude/Codex-compatible streams.
*/
function stringifyContent(value) {
import { Buffer } from 'node:buffer';
type JsonRecord = Record<string, unknown>;
type QoderEvent = Record<string, unknown>;
type QoderEventSink = (event: QoderEvent) => void;
function isRecord(value: unknown): value is JsonRecord {
return Boolean(value) && typeof value === 'object';
}
function stringifyContent(value: unknown): string {
if (typeof value === 'string') return value;
if (Buffer.isBuffer(value)) return value.toString('utf8');
if (value == null) return '';
try {
return JSON.stringify(value);
@ -16,26 +26,25 @@ function stringifyContent(value) {
}
}
function textFromContentBlock(block) {
if (!block || typeof block !== 'object') return '';
function textFromContentBlock(block: unknown): string {
if (!isRecord(block)) return '';
if (block.type === 'text' && typeof block.text === 'string') return block.text;
if (typeof block.text === 'string') return block.text;
return '';
}
function messageFromError(error) {
if (error && typeof error === 'object' && typeof error.message === 'string') {
function messageFromError(error: unknown): string {
if (isRecord(error) && typeof error.message === 'string') {
return error.message;
}
if (typeof error === 'string' && error.length > 0) return error;
return 'Unknown Qoder error';
}
function messageFromResult(obj) {
function messageFromResult(obj: JsonRecord): string {
if (typeof obj.error === 'string' && obj.error.length > 0) return obj.error;
if (
obj.error &&
typeof obj.error === 'object' &&
isRecord(obj.error) &&
typeof obj.error.message === 'string' &&
obj.error.message.length > 0
) {
@ -50,12 +59,12 @@ function messageFromResult(obj) {
return 'Qoder run failed';
}
export function createQoderStreamHandler(onEvent) {
export function createQoderStreamHandler(onEvent: QoderEventSink) {
let buffer = '';
let emittedThinkingStart = false;
function handleObject(obj, rawLine) {
if (!obj || typeof obj !== 'object') return;
function handleObject(obj: unknown, rawLine: string) {
if (!isRecord(obj)) return;
if (obj.type === 'system' && obj.subtype === 'init') {
onEvent({
@ -71,7 +80,7 @@ export function createQoderStreamHandler(onEvent) {
return;
}
if (obj.type === 'assistant' && obj.message) {
if (obj.type === 'assistant' && isRecord(obj.message)) {
const content = Array.isArray(obj.message.content)
? obj.message.content
: [];
@ -84,8 +93,7 @@ export function createQoderStreamHandler(onEvent) {
continue;
}
if (
block &&
typeof block === 'object' &&
isRecord(block) &&
block.type === 'thinking' &&
typeof block.thinking === 'string' &&
block.thinking.length > 0
@ -135,7 +143,7 @@ export function createQoderStreamHandler(onEvent) {
onEvent({ type: 'raw', line: rawLine });
}
function handleLine(line) {
function handleLine(line: string) {
try {
handleObject(JSON.parse(line), line);
} catch {
@ -143,7 +151,7 @@ export function createQoderStreamHandler(onEvent) {
}
}
function feed(chunk) {
function feed(chunk: unknown) {
buffer += stringifyContent(chunk);
let nl;
while ((nl = buffer.indexOf('\n')) !== -1) {

View file

@ -8,6 +8,7 @@ export function createChatRunService({
createSseErrorPayload,
maxEvents = 2_000,
ttlMs = 30 * 60 * 1000,
shutdownGraceMs = 3_000,
}) {
const runs = new Map();
@ -121,6 +122,36 @@ export function createChatRunService({
return true;
});
const waitForChildExit = (child, timeoutMs) => {
if (!child) return Promise.resolve(true);
if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve(true);
return new Promise((resolve) => {
let settled = false;
const done = (exited) => {
if (settled) return;
settled = true;
clearTimeout(timer);
child.off?.('close', onClose);
child.off?.('exit', onClose);
resolve(exited);
};
const onClose = () => done(true);
const timer = setTimeout(() => done(false), timeoutMs);
timer.unref?.();
child.once?.('close', onClose);
child.once?.('exit', onClose);
});
};
const killChild = (run, signal) => {
if (!run.child || run.child.exitCode !== null || run.child.signalCode !== null) return false;
try {
return run.child.kill(signal);
} catch {
return false;
}
};
const cancel = (run) => {
if (!TERMINAL_RUN_STATUSES.has(run.status)) {
run.cancelRequested = true;
@ -143,6 +174,27 @@ export function createChatRunService({
}
};
const shutdownActive = async ({ graceMs = shutdownGraceMs } = {}) => {
const activeRuns = Array.from(runs.values()).filter((run) => !TERMINAL_RUN_STATUSES.has(run.status));
await Promise.all(activeRuns.map(async (run) => {
run.cancelRequested = true;
run.updatedAt = Date.now();
if (run.acpSession?.abort) {
try {
run.acpSession.abort();
} catch {
// Process signals below are the shutdown fallback.
}
}
killChild(run, 'SIGTERM');
finish(run, 'canceled', null, 'SIGTERM');
if (run.child && !(await waitForChildExit(run.child, graceMs))) {
killChild(run, 'SIGKILL');
await waitForChildExit(run.child, 500);
}
}));
};
const wait = (run) => {
if (TERMINAL_RUN_STATUSES.has(run.status)) return Promise.resolve(statusBody(run));
return new Promise((resolve) => run.waiters.add(resolve));
@ -155,6 +207,7 @@ export function createChatRunService({
list,
stream,
cancel,
shutdownActive,
wait,
emit,
finish,

View file

@ -35,8 +35,10 @@ import {
deleteUserSkill,
findSkillById,
importUserSkill,
listSkillFiles,
listSkills,
splitDerivedSkillId,
updateUserSkill,
} from './skills.js';
import { validateLinkedDirs } from './linked-dirs.js';
import { buildWindowsFolderDialogCommand, parseFolderDialogStdout } from './native-folder-dialog.js';
@ -59,11 +61,17 @@ import { renderDesignSystemPreview } from './design-system-preview.js';
import { renderDesignSystemShowcase } from './design-system-showcase.js';
import { createChatRunService } from './runs.js';
import {
redactSecrets,
testAgentConnection,
testProviderConnection,
validateBaseUrl,
} from './connectionTest.js';
import { importClaudeDesignZip } from './claude-design-import.js';
import {
finalizeDesignPackage,
FinalizePackageLockedError,
FinalizeUpstreamError,
} from './finalize-design.js';
import { listPromptTemplates, readPromptTemplate } from './prompt-templates.js';
import { buildDocumentPreview } from './document-preview.js';
import { lintArtifact, renderFindingsForAgent } from './lint-artifact.js';
@ -113,6 +121,7 @@ import {
deleteProjectFile,
detectEntryFile,
ensureProject,
isSafeId,
listFiles,
mimeFor,
projectDir,
@ -457,6 +466,7 @@ export function validateCodexGeneratedImagesDir(
export function resolveChatExtraAllowedDirs({
agentId,
skillsDir,
designTemplatesDir,
designSystemsDir,
linkedDirs = [],
codexGeneratedImagesDir,
@ -464,6 +474,7 @@ export function resolveChatExtraAllowedDirs({
}: {
agentId?: string | null;
skillsDir?: string | null;
designTemplatesDir?: string | null;
designSystemsDir?: string | null;
linkedDirs?: Array<string | null | undefined>;
codexGeneratedImagesDir?: string | null;
@ -475,6 +486,12 @@ export function resolveChatExtraAllowedDirs({
? [codexGeneratedImagesDir]
: [
skillsDir,
// Design templates live under their own root after the
// skills/design-templates split, so they need to be allow-listed
// alongside the functional skills root for the same reason: the
// active skill's folder is mounted at <cwd>/.od-skills/<folder>/
// and the agent may also reach for the absolute fallback path.
designTemplatesDir,
designSystemsDir,
...(Array.isArray(linkedDirs) ? linkedDirs : []),
];
@ -717,6 +734,16 @@ const SKILLS_DIR = resolveDaemonResourceDir(
'skills',
path.join(PROJECT_ROOT, 'skills'),
);
// Design templates are SKILL.md folders that primarily ship a render
// template (deck/prototype/image/video/audio "shapes"). They live in their
// own root so the Settings → Skills surface can stay focused on functional
// skills (utility tools, briefs, packagers) while the EntryView Templates
// tab gets the large rendering catalogue. See specs/current/skills-and-design-templates.md.
const DESIGN_TEMPLATES_DIR = resolveDaemonResourceDir(
DAEMON_RESOURCE_ROOT,
'design-templates',
path.join(PROJECT_ROOT, 'design-templates'),
);
const DESIGN_SYSTEMS_DIR = resolveDaemonResourceDir(
DAEMON_RESOURCE_ROOT,
'design-systems',
@ -800,7 +827,25 @@ fs.mkdirSync(PROJECTS_DIR, { recursive: true });
// name without erasing the bundled copy.
const USER_SKILLS_DIR = path.join(RUNTIME_DATA_DIR, 'user-skills');
fs.mkdirSync(USER_SKILLS_DIR, { recursive: true });
// User-imported design templates land here, mirroring USER_SKILLS_DIR for
// the design-templates root. The two directories stay separate so the
// Settings → Skills surface and the EntryView Templates surface each
// CRUD their own slice of the user library without collision.
const USER_DESIGN_TEMPLATES_DIR = path.join(RUNTIME_DATA_DIR, 'user-design-templates');
fs.mkdirSync(USER_DESIGN_TEMPLATES_DIR, { recursive: true });
const SKILL_ROOTS = [USER_SKILLS_DIR, SKILLS_DIR];
const DESIGN_TEMPLATE_ROOTS = [USER_DESIGN_TEMPLATES_DIR, DESIGN_TEMPLATES_DIR];
// Lookup roots used wherever we need to resolve a skill id without caring
// whether it points at a functional skill or a design template — chat run
// system-prompt composition and the orbit template resolver both take
// stored project ids that may have come from either surface. Keep this
// in sync with SKILL_ROOTS + DESIGN_TEMPLATE_ROOTS.
const ALL_SKILL_LIKE_ROOTS = [
USER_SKILLS_DIR,
USER_DESIGN_TEMPLATES_DIR,
SKILLS_DIR,
DESIGN_TEMPLATES_DIR,
];
const orbitService = new OrbitService(RUNTIME_DATA_DIR);
@ -1601,6 +1646,7 @@ function sendMulterError(res, err) {
LIMIT_FIELD_KEY: 400,
LIMIT_FIELD_VALUE: 400,
LIMIT_FIELD_COUNT: 400,
MISSING_FIELD_NAME: 400,
};
const errorByCode = {
LIMIT_FILE_SIZE: 'file too large',
@ -1610,6 +1656,7 @@ function sendMulterError(res, err) {
LIMIT_FIELD_KEY: 'field name too long',
LIMIT_FIELD_VALUE: 'field value too long',
LIMIT_FIELD_COUNT: 'too many form fields',
MISSING_FIELD_NAME: 'missing field name',
};
const status = statusByCode[code] ?? 400;
const message = errorByCode[code] ?? 'upload failed';
@ -1712,7 +1759,7 @@ export function createSseResponse(
return {
/** @param {ChatSseEvent['event'] | ProxySseEvent['event'] | string} event */
send(event, data, id = null) {
send(event, data, id: string | number | null | undefined = null) {
if (!canWrite()) return false;
if (id !== null && id !== undefined) res.write(`id: ${id}\n`);
res.write(`event: ${event}\n`);
@ -1736,8 +1783,15 @@ function resolveChatRunInactivityTimeoutMs() {
return Math.max(0, Math.floor(raw));
}
function resolveChatRunShutdownGraceMs() {
const raw = Number(process.env.OD_CHAT_RUN_SHUTDOWN_GRACE_MS);
if (!Number.isFinite(raw)) return 3_000;
return Math.max(0, Math.floor(raw));
}
export async function startServer({ port = 7456, host = process.env.OD_BIND_HOST || '127.0.0.1', returnServer = false } = {}) {
let resolvedPort = port;
let daemonShuttingDown = false;
const extraAllowedOrigins = configuredAllowedOrigins();
const app = express();
app.use(express.json({ limit: '4mb' }));
@ -2909,6 +2963,36 @@ export async function startServer({ port = 7456, host = process.env.OD_BIND_HOST
}
});
// Design templates — the rendering catalogue. Same shape as /api/skills
// (so the web client can reuse SkillSummary types) but rooted at
// DESIGN_TEMPLATE_ROOTS so the listing stays focused on template-style
// entries without bleeding functional skills into the EntryView gallery.
app.get('/api/design-templates', async (_req, res) => {
try {
const templates = await listSkills(DESIGN_TEMPLATE_ROOTS);
res.json({
designTemplates: templates.map(({ body, dir: _dir, ...rest }) => ({
...rest,
hasBody: typeof body === 'string' && body.length > 0,
})),
});
} catch (err) {
res.status(500).json({ error: String(err) });
}
});
app.get('/api/design-templates/:id', async (req, res) => {
try {
const templates = await listSkills(DESIGN_TEMPLATE_ROOTS);
const tpl = findSkillById(templates, req.params.id);
if (!tpl) return res.status(404).json({ error: 'design template not found' });
const { dir: _dir, ...serializable } = tpl;
res.json(serializable);
} catch (err) {
res.status(500).json({ error: String(err) });
}
});
// Import a user-authored skill. Body: { name, description?, body, triggers? }.
// Writes a SKILL.md under USER_SKILLS_DIR; the next /api/skills request
// will surface it. Mirrors the validation layer in skills.ts, returning
@ -2943,6 +3027,97 @@ export async function startServer({ port = 7456, host = process.env.OD_BIND_HOST
}
});
// Update an existing skill's SKILL.md body. For user skills this is an
// overwrite; for built-in skills we write a "shadow" copy under
// USER_SKILLS_DIR/<slug>/ which the next listSkills() pass surfaces in
// place of the bundled copy. The old built-in body resurfaces if the
// user later deletes the shadow via DELETE /api/skills/:id. The body
// shape mirrors POST /api/skills/import; `name` in the body must
// resolve to the same id as the route param so the user cannot rename
// mid-edit (rename = delete + import).
app.put('/api/skills/:id', async (req, res) => {
try {
const skills = await listSkills(SKILL_ROOTS);
const skill = findSkillById(skills, req.params.id);
if (!skill) {
return res
.status(404)
.json({ error: { code: 'NOT_FOUND', message: 'skill not found' } });
}
const body = req.body && typeof req.body === 'object' ? req.body : {};
const incomingName =
typeof body.name === 'string' ? body.name.trim() : skill.id;
if (incomingName !== skill.id) {
return res.status(400).json({
error: {
code: 'BAD_REQUEST',
message:
'renaming a skill requires deleting the old id and importing under the new name',
},
});
}
const result = await updateUserSkill(USER_SKILLS_DIR, {
...body,
name: skill.id,
});
const next = await listSkills(SKILL_ROOTS);
const updated = findSkillById(next, result.id);
if (!updated) {
return res.status(500).json({
error: {
code: 'INTERNAL_ERROR',
message: 'updated skill could not be re-read',
},
});
}
const { dir: _dir, body: _body, ...summary } = updated;
res.json({
skill: {
...summary,
hasBody: typeof updated.body === 'string' && updated.body.length > 0,
},
});
} catch (err) {
if (err instanceof SkillImportError) {
const status =
err.code === 'BAD_REQUEST'
? 400
: err.code === 'NOT_FOUND'
? 404
: 500;
return res
.status(status)
.json({ error: { code: err.code, message: err.message } });
}
res
.status(500)
.json({ error: { code: 'INTERNAL_ERROR', message: String(err) } });
}
});
// Lightweight on-disk listing for the Settings → Skills detail panel.
// Spans both functional skills and design templates so a user can
// inspect a template's bundled assets too. Returns a flat list of
// entries (path + kind + size) rather than a nested tree so the UI
// can render whatever shape it likes without re-walking on the client.
app.get('/api/skills/:id/files', async (req, res) => {
try {
const skills = await listSkills(ALL_SKILL_LIKE_ROOTS);
const skill = findSkillById(skills, req.params.id);
if (!skill) {
return res
.status(404)
.json({ error: { code: 'NOT_FOUND', message: 'skill not found' } });
}
const files = await listSkillFiles(skill.dir);
res.json({ files });
} catch (err) {
res
.status(500)
.json({ error: { code: 'INTERNAL_ERROR', message: String(err) } });
}
});
// Delete a user-imported skill. Built-in skills (under SKILLS_DIR) are
// refused — we only allow removal of folders the daemon itself wrote
// under USER_SKILLS_DIR.
@ -3151,7 +3326,11 @@ export async function startServer({ port = 7456, host = process.env.OD_BIND_HOST
// a real preview on its parent card instead of returning 404.
app.get('/api/skills/:id/example', async (req, res) => {
try {
const skills = await listSkills(SKILL_ROOTS);
// Look across both functional skills and design templates: rendered
// example HTML rewrites assets to /api/skills/<id>/... and we want
// those URLs to keep resolving regardless of which root owns the
// backing folder after the skills/design-templates split.
const skills = await listSkills(ALL_SKILL_LIKE_ROOTS);
// 1. Derived `<parent>:<child>` id — resolve straight to the matching
// file under <parentDir>/examples/. Done before findSkillById so the
@ -3273,7 +3452,9 @@ export async function startServer({ port = 7456, host = process.env.OD_BIND_HOST
// contributors can preview `example.html` straight from disk.
app.get('/api/skills/:id/assets/*', async (req, res) => {
try {
const skills = await listSkills(SKILL_ROOTS);
// Same rationale as /example above — assets need to resolve whether
// the owning skill folder lives under skills/ or design-templates/.
const skills = await listSkills(ALL_SKILL_LIKE_ROOTS);
const skill = findSkillById(skills, req.params.id);
if (!skill) {
return res.status(404).type('text/plain').send('skill not found');
@ -3843,6 +4024,101 @@ export async function startServer({ port = 7456, host = process.env.OD_BIND_HOST
}
});
app.post('/api/projects/:id/finalize/anthropic', async (req, res) => {
const { apiKey, baseUrl, model, maxTokens } = req.body || {};
try {
// Centralized path-traversal guard. `isSafeId` (apps/daemon/src/projects.ts)
// rejects pure-dot ids (`.`, `..`, etc.) which would otherwise pass
// the char-class regex and resolve to the parent directory under
// path.join. Express decodes percent-encoded `%2e%2e` to `..` before
// we see it, so this check covers both URL-supplied and stored-row
// attack vectors.
if (!isSafeId(req.params.id)) {
return sendApiError(res, 400, 'BAD_REQUEST', 'invalid project id');
}
if (typeof apiKey !== 'string' || !apiKey.trim()) {
return sendApiError(res, 400, 'BAD_REQUEST', 'apiKey is required');
}
if (typeof model !== 'string' || !model.trim()) {
return sendApiError(res, 400, 'BAD_REQUEST', 'model is required');
}
if (baseUrl !== undefined) {
if (typeof baseUrl !== 'string' || !baseUrl.trim()) {
return sendApiError(res, 400, 'BAD_REQUEST', 'baseUrl must be a non-empty string when provided');
}
const validated = validateExternalApiBaseUrl(baseUrl);
if (validated.error) {
return sendApiError(
res,
validated.forbidden ? 403 : 400,
validated.forbidden ? 'FORBIDDEN' : 'BAD_REQUEST',
validated.error,
);
}
}
if (maxTokens !== undefined && (typeof maxTokens !== 'number' || maxTokens <= 0)) {
return sendApiError(res, 400, 'BAD_REQUEST', 'maxTokens must be a positive number when provided');
}
const project = getProject(db, req.params.id);
if (!project) {
return sendApiError(res, 404, 'PROJECT_NOT_FOUND', 'project not found');
}
const result = await finalizeDesignPackage(
db,
PROJECTS_DIR,
DESIGN_SYSTEMS_DIR,
req.params.id,
{ apiKey, baseUrl, model, maxTokens },
);
res.json(result);
} catch (err) {
// Concurrent finalize - the lockfile was already held by another
// call. Caller can retry after a short wait; not a client error.
// Maps to the shared CONFLICT code per @lefarcen P2 on PR #832.
if (err instanceof FinalizePackageLockedError) {
return sendApiError(res, 409, 'CONFLICT', err.message);
}
// Upstream Anthropic error - status-aware mapping using shared
// ApiErrorCode values. Run the raw upstream body through
// redactSecrets so the API key cannot leak even if Anthropic
// echoes the inbound headers. Codes per @lefarcen P2 on PR #832:
// 401 -> UNAUTHORIZED, 429 -> RATE_LIMITED, others -> UPSTREAM_UNAVAILABLE.
if (err instanceof FinalizeUpstreamError) {
const safeDetails = redactSecrets(err.rawText || '', [apiKey]);
const init = safeDetails ? { details: safeDetails } : {};
if (err.status === 401) {
return sendApiError(res, 401, 'UNAUTHORIZED', err.message, init);
}
if (err.status === 429) {
return sendApiError(res, 429, 'RATE_LIMITED', err.message, init);
}
return sendApiError(res, 502, 'UPSTREAM_UNAVAILABLE', err.message, init);
}
// The blocking call hit our 120s AbortController timeout - or the
// caller passed an already-aborted signal. Either way, surface as
// 503 with the shared UPSTREAM_UNAVAILABLE code (no dedicated
// TIMEOUT code in the contracts ApiErrorCode union).
const errName =
err && typeof err === 'object' && 'name' in err ? (err as { name?: unknown }).name : '';
if (errName === 'AbortError') {
return sendApiError(res, 503, 'UPSTREAM_UNAVAILABLE', 'finalize timed out');
}
// Unexpected runtime failure (file IO, db access, prompt build).
// Log via console.error per the daemon convention; client sees a
// generic 500 with the shared INTERNAL_ERROR code. Run the message
// through redactSecrets defensively.
console.error('[finalize/anthropic]', err);
const safeMsg = redactSecrets(String(err?.message || err), [apiKey]);
return sendApiError(res, 500, 'INTERNAL_ERROR', safeMsg);
}
});
app.post(
'/api/projects/:id/deployments/:deploymentId/check-link',
async (req, res) => {
@ -4599,7 +4875,11 @@ export async function startServer({ port = 7456, host = process.env.OD_BIND_HOST
.filter(Boolean)
.filter((id) => id !== effectiveSkillId)
: [];
const allSkills = await listSkills(SKILL_ROOTS);
// Compose lookup spans both functional skills and design templates so
// a project saved against either surface keeps its system prompt.
// After the skills/design-templates split (see specs/current/skills-and-design-templates.md)
// a project's `skillId` can resolve to either root.
const allSkills = await listSkills(ALL_SKILL_LIKE_ROOTS);
let skillBody;
let skillName;
let skillMode;
@ -5026,13 +5306,19 @@ export async function startServer({ port = 7456, host = process.env.OD_BIND_HOST
codexGeneratedImagesDir = validateCodexGeneratedImagesDir(
codexGeneratedImagesDir,
{
protectedDirs: [SKILLS_DIR, DESIGN_SYSTEMS_DIR, ...linkedDirs],
protectedDirs: [
SKILLS_DIR,
DESIGN_TEMPLATES_DIR,
DESIGN_SYSTEMS_DIR,
...linkedDirs,
],
},
);
}
const extraAllowedDirs = resolveChatExtraAllowedDirs({
agentId,
skillsDir: SKILLS_DIR,
designTemplatesDir: DESIGN_TEMPLATES_DIR,
designSystemsDir: DESIGN_SYSTEMS_DIR,
linkedDirs,
codexGeneratedImagesDir,
@ -5862,7 +6148,10 @@ export async function startServer({ port = 7456, host = process.env.OD_BIND_HOST
});
orbitService.setTemplateResolver(async (skillId) => {
const skills = await listSkills(SKILL_ROOTS);
// Orbit templates (live-artifact, etc.) live under design-templates after
// the split, but earlier projects may still point at functional skill
// ids for the same purpose — search both roots.
const skills = await listSkills(ALL_SKILL_LIKE_ROOTS);
const skill = findSkillById(skills, skillId);
if (!skill || skill.scenario !== 'orbit') return null;
return {
@ -5876,6 +6165,9 @@ export async function startServer({ port = 7456, host = process.env.OD_BIND_HOST
});
app.post('/api/runs', (req, res) => {
if (daemonShuttingDown) {
return sendApiError(res, 503, 'UPSTREAM_UNAVAILABLE', 'daemon is shutting down');
}
const run = design.runs.create(req.body || {});
/** @type {import('@open-design/contracts').ChatRunCreateResponse} */
const body = { runId: run.id };
@ -5913,6 +6205,9 @@ export async function startServer({ port = 7456, host = process.env.OD_BIND_HOST
});
app.post('/api/chat', (req, res) => {
if (daemonShuttingDown) {
return sendApiError(res, 503, 'UPSTREAM_UNAVAILABLE', 'daemon is shutting down');
}
const run = design.runs.create();
design.runs.stream(run, req, res);
design.runs.start(run, () => startChatRun(req.body || {}, run));
@ -6257,6 +6552,7 @@ export async function startServer({ port = 7456, host = process.env.OD_BIND_HOST
'anthropic-version': '2023-06-01',
},
body: JSON.stringify(payload),
redirect: 'error',
});
if (!response.ok) {
@ -6352,6 +6648,7 @@ export async function startServer({ port = 7456, host = process.env.OD_BIND_HOST
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify(payload),
redirect: 'error',
});
if (!response.ok) {
@ -6451,6 +6748,7 @@ export async function startServer({ port = 7456, host = process.env.OD_BIND_HOST
'api-key': apiKey,
},
body: JSON.stringify(payload),
redirect: 'error',
});
if (!response.ok) {
@ -6548,6 +6846,7 @@ export async function startServer({ port = 7456, host = process.env.OD_BIND_HOST
'x-goog-api-key': apiKey,
},
body: JSON.stringify(payload),
redirect: 'error',
});
if (!response.ok) {
@ -6595,14 +6894,21 @@ export async function startServer({ port = 7456, host = process.env.OD_BIND_HOST
// critical when port=0 (ephemeral port) and when the embedding sidecar
// needs to advertise the port to a parent process before any request
// can flow. Three callers depend on this contract:
// - `apps/daemon/src/cli.ts` → expects a `url` string
// - `apps/daemon/src/cli.ts` → expects `{ url, server, shutdown }`
// - `apps/daemon/sidecar/server.ts` → expects `{ url, server }`
// - `apps/daemon/tests/version-route.test.ts` → expects `{ url, server }`
return await new Promise((resolve, reject) => {
let daemonShutdownStarted = false;
const cleanupDaemonBackgroundWork = () => {
composioConnectorProvider.stopCatalogRefreshLoop();
orbitService.stop();
};
const shutdownDaemonRuns = async () => {
if (daemonShutdownStarted) return;
daemonShutdownStarted = true;
daemonShuttingDown = true;
await design.runs.shutdownActive({ graceMs: resolveChatRunShutdownGraceMs() });
};
let server;
try {
server = app.listen(port, host, () => {
@ -6631,14 +6937,16 @@ export async function startServer({ port = 7456, host = process.env.OD_BIND_HOST
console.log(`[od] daemon listening on ${url}`);
}
daemonUrl = url;
resolve(returnServer ? { url, server } : url);
resolve(returnServer ? { url, server, shutdown: shutdownDaemonRuns } : url);
});
} catch (error) {
cleanupDaemonBackgroundWork();
reject(error);
return;
}
server.once('close', cleanupDaemonBackgroundWork);
server.once('close', () => {
void shutdownDaemonRuns().finally(cleanupDaemonBackgroundWork);
});
// `app.listen` throws synchronously when the port is already in use on
// some Node versions, but emits an `error` event on others (and for
// EACCES / EADDRNOTAVAIL even on the same Node). Wire the event so the

View file

@ -18,6 +18,12 @@ import { startServer } from "../server.js";
const DAEMON_PORT_ENV = SIDECAR_ENV.DAEMON_PORT;
const TOOLS_DEV_PARENT_PID_ENV = SIDECAR_ENV.TOOLS_DEV_PARENT_PID;
type StartedDaemonServer = {
server: Server;
url: string;
shutdown?: () => Promise<void>;
};
export type DaemonSidecarHandle = {
status(): Promise<DaemonStatusSnapshot>;
stop(): Promise<void>;
@ -33,10 +39,39 @@ function parsePort(value: string | undefined): number {
return port;
}
async function closeHttpServer(server: Server): Promise<void> {
export async function closeHttpServer(
server: Server,
{ closeTimeoutMs = 5_000, idleCloseMs = 1_000 } = {},
): Promise<void> {
if (!server.listening) return;
await new Promise<void>((resolveClose, rejectClose) => {
server.close((error) => (error == null ? resolveClose() : rejectClose(error)));
let resolved = false;
const resolveOnce = () => {
if (resolved) return;
resolved = true;
clearTimeout(idleTimer);
clearTimeout(hardTimer);
resolveClose();
};
const rejectOnce = (error: Error) => {
if (resolved) return;
resolved = true;
clearTimeout(idleTimer);
clearTimeout(hardTimer);
rejectClose(error);
};
const idleTimer = setTimeout(() => {
server.closeIdleConnections?.();
}, Math.min(idleCloseMs, closeTimeoutMs));
const hardTimer = setTimeout(() => {
server.closeAllConnections?.();
resolveOnce();
}, closeTimeoutMs);
idleTimer.unref?.();
hardTimer.unref?.();
server.close((error) => (error == null ? resolveOnce() : rejectOnce(error)));
}).finally(() => {
server.closeIdleConnections?.();
});
}
@ -64,7 +99,7 @@ function attachParentMonitor(stop: () => Promise<void>): void {
export async function startDaemonSidecar(runtime: SidecarRuntimeContext<SidecarStamp>): Promise<DaemonSidecarHandle> {
const started = await startServer({ port: parsePort(process.env[DAEMON_PORT_ENV]), returnServer: true }) as
| string
| { server: Server; url: string };
| StartedDaemonServer;
if (typeof started === "string") {
throw new Error("daemon startServer did not return a server handle");
}
@ -88,8 +123,12 @@ export async function startDaemonSidecar(runtime: SidecarRuntimeContext<SidecarS
stopped = true;
state.state = "stopped";
state.updatedAt = new Date().toISOString();
const closePromise = closeHttpServer(serverHandle.server).catch(() => undefined);
const shutdownPromise = serverHandle.shutdown?.().catch((error: unknown) => {
console.error("daemon shutdown cleanup failed", error);
}) ?? Promise.resolve();
await ipcServer?.close().catch(() => undefined);
await closeHttpServer(serverHandle.server).catch(() => undefined);
await Promise.allSettled([closePromise, shutdownPromise]);
resolveStopped();
}

View file

@ -1,4 +1,3 @@
// @ts-nocheck
// Skill registry. Scans one or more on-disk roots for SKILL.md files, parses
// front-matter, returns listing. No watching in this MVP — re-scans on every
// GET /api/skills, which is fine for dozens of skills.
@ -7,6 +6,7 @@
// so user-imported skills under USER_SKILLS_DIR can shadow a built-in skill
// of the same name without erasing the built-in copy.
import type { Dirent } from "node:fs";
import { mkdir, readdir, readFile, rm, stat, writeFile } from "node:fs/promises";
import path from "node:path";
import { parseFrontmatter } from "./frontmatter.js";
@ -25,36 +25,100 @@ export const SKILL_ID_ALIASES = Object.freeze({
"editorial-collage-deck": "open-design-landing-deck",
});
export function resolveSkillId(id) {
type SkillMode = "image" | "video" | "audio" | "deck" | "design-system" | "template" | "prototype";
type SkillSurface = "web" | "image" | "video" | "audio";
type SkillPlatform = "desktop" | "mobile" | null;
type JsonRecord = Record<string, unknown>;
interface SkillFrontmatter extends JsonRecord {
name?: unknown;
description?: unknown;
triggers?: unknown;
od?: JsonRecord & { craft?: JsonRecord; preview?: JsonRecord; design_system?: JsonRecord };
}
// Indicates whether a skill came from a user-writable root (the first root
// passed to listSkills) or from a built-in repo root (any later root). The
// UI uses this to render an origin pill and to gate destructive actions:
// only `user` skills can be deleted via /api/skills/:id.
export type SkillSource = "user" | "built-in";
export interface SkillInfo {
id: string;
name: string;
description: string;
triggers: unknown[];
mode: SkillMode;
surface: SkillSurface;
source: SkillSource;
craftRequires: string[];
platform: SkillPlatform;
scenario: string;
previewType: string;
designSystemRequired: boolean;
defaultFor: string[];
upstream: string | null;
featured: number | null;
fidelity: "wireframe" | "high-fidelity" | null;
speakerNotes: boolean | null;
animations: boolean | null;
examplePrompt: string;
aggregatesExamples: boolean;
body: string;
dir: string;
}
interface DerivedExample {
key: string;
}
export interface DerivedSkillIdParts {
parentId: string;
childKey: string;
}
function isRecord(value: unknown): value is JsonRecord {
return Boolean(value) && typeof value === "object";
}
function asSkillFrontmatter(value: unknown): SkillFrontmatter {
return isRecord(value) ? (value as SkillFrontmatter) : {};
}
export function resolveSkillId(id: unknown): unknown {
if (typeof id !== "string" || id.length === 0) return id;
return SKILL_ID_ALIASES[id] ?? id;
return (SKILL_ID_ALIASES as Readonly<Record<string, string>>)[id] ?? id;
}
// Lookup helper that mirrors `skills.find((s) => s.id === id)` but first
// rewrites any deprecated id to its current canonical form. Use this at
// every site that resolves a stored or external skill id; calling
// `.find()` directly will silently miss aliased ids.
export function findSkillById(skills, id) {
export function findSkillById(skills: unknown, id: unknown): SkillInfo | undefined {
if (!Array.isArray(skills) || typeof id !== "string" || id.length === 0) {
return undefined;
}
const canonical = resolveSkillId(id);
return skills.find((s) => s.id === canonical);
return (skills as SkillInfo[]).find((s) => s.id === canonical);
}
// Accept either a single root or an array. The first root wins on id
// collisions so user-imported skills can shadow a built-in by the same name.
// Each surfaced summary carries a `source` ("built-in" | "user") so the UI
// can render an origin pill and gate the delete control.
export async function listSkills(skillsRoots: any): Promise<any[]> {
// Accept either a single root path or an array. When given multiple roots,
// the first one wins on id collisions so user-imported skills under
// USER_SKILLS_DIR can shadow a built-in skill of the same name without
// erasing the bundled copy. Each surfaced summary carries a `source`
// (`"user"` for the first root, `"built-in"` for any later root) so the
// UI can render an origin pill and gate the delete control.
export async function listSkills(
skillsRoots: string | readonly string[],
): Promise<SkillInfo[]> {
const roots = Array.isArray(skillsRoots) ? skillsRoots : [skillsRoots];
const out: any[] = [];
const seen: Set<string> = new Set();
for (let i = 0; i < roots.length; i += 1) {
const skillsRoot = roots[i];
const out: SkillInfo[] = [];
const seenIds = new Set<string>();
for (let rootIdx = 0; rootIdx < roots.length; rootIdx += 1) {
const skillsRoot = roots[rootIdx];
if (!skillsRoot) continue;
const source = i === 0 ? "user" : "built-in";
let entries = [];
const source: SkillSource = rootIdx === 0 ? "user" : "built-in";
let entries: Dirent[] = [];
try {
entries = await readdir(skillsRoot, { withFileTypes: true });
} catch {
@ -68,41 +132,59 @@ export async function listSkills(skillsRoots: any): Promise<any[]> {
const stats = await stat(skillPath);
if (!stats.isFile()) continue;
const raw = await readFile(skillPath, "utf8");
const { data, body } = parseFrontmatter(raw);
const parentId = data.name || entry.name;
if (seen.has(parentId)) continue;
seen.add(parentId);
const { data: parsedData, body } = parseFrontmatter(raw) as {
data: unknown;
body: string;
};
const data = asSkillFrontmatter(parsedData);
const parentId =
typeof data.name === "string" && data.name ? data.name : entry.name;
// Skip when an earlier root already surfaced this id — the first
// root wins so user shadows built-in. Done before we read the
// rest of the frontmatter to keep the shadowed-skill path cheap.
if (seenIds.has(parentId)) continue;
seenIds.add(parentId);
const hasAttachments = await dirHasAttachments(dir);
const mode = data.od?.mode || inferMode(body, data.description);
const mode = normalizeMode(data.od?.mode, body, data.description);
const surface = normalizeSurface(data.od?.surface, mode);
const platform = normalizePlatform(
data.od?.platform,
mode,
body,
data.description
data.description,
);
const scenario = normalizeScenario(
data.od?.scenario,
body,
data.description
data.description,
);
const designSystemRequired = data.od?.design_system?.requires ?? true;
const designSystemRequired =
typeof data.od?.design_system?.requires === "boolean"
? data.od.design_system.requires
: true;
const upstream =
typeof data.od?.upstream === "string" ? data.od.upstream : null;
const previewType = data.od?.preview?.type || "html";
const parentBody = hasAttachments ? withSkillRootPreamble(body, dir) : body;
const previewType =
typeof data.od?.preview?.type === "string"
? data.od.preview.type
: "html";
const description =
typeof data.description === "string" ? data.description : "";
const parentBody = hasAttachments
? withSkillRootPreamble(body, dir)
: body;
// Pre-compute derived examples so the parent entry can advertise
// `aggregatesExamples` in the same push. The frontend uses that
// flag to hide the parent card from the gallery (its preview
// would duplicate one of the derived cards), while the daemon
// keeps the parent in the listing so `findSkillById` still
// resolves it for system-prompt composition and id alias lookups.
// flag to hide the parent card from the gallery (its preview would
// duplicate one of the derived cards), while the daemon keeps the
// parent in the listing so `findSkillById` still resolves it for
// system-prompt composition and id alias lookups.
const derivedExamples = await collectDerivedExamples(dir);
const aggregatesExamples = derivedExamples.length > 0;
out.push({
id: parentId,
name: parentId,
description: data.description || "",
description,
triggers: Array.isArray(data.triggers) ? data.triggers : [],
mode,
surface,
@ -139,12 +221,12 @@ export async function listSkills(skillsRoots: any): Promise<any[]> {
// magazine row.
for (const example of derivedExamples) {
const derivedId = `${parentId}:${example.key}`;
if (seen.has(derivedId)) continue;
seen.add(derivedId);
if (seenIds.has(derivedId)) continue;
seenIds.add(derivedId);
out.push({
id: derivedId,
name: humanizeExampleName(example.key),
description: data.description || "",
description,
triggers: Array.isArray(data.triggers) ? data.triggers : [],
mode,
surface,
@ -191,15 +273,15 @@ export async function listSkills(skillsRoots: any): Promise<any[]> {
// To ship a subfolder-style example, place the baked output beside the
// folder as `examples/<name>.html` (the canonical render) and keep the
// subfolder around as agent-readable source.
async function collectDerivedExamples(dir) {
async function collectDerivedExamples(dir: string): Promise<DerivedExample[]> {
const examplesDir = path.join(dir, "examples");
let entries = [];
let entries: Dirent[] = [];
try {
entries = await readdir(examplesDir, { withFileTypes: true });
} catch {
return [];
}
const out = [];
const out: DerivedExample[] = [];
for (const entry of entries) {
if (!entry.isFile()) continue;
if (!entry.name.toLowerCase().endsWith(".html")) continue;
@ -215,7 +297,7 @@ async function collectDerivedExamples(dir) {
// Reject keys that could escape the examples folder or break the
// `<parent>:<child>` id format. Letters/digits/dash/dot/underscore only,
// and never the dotfile path-traversal patterns.
function isSafeExampleKey(key) {
function isSafeExampleKey(key: string): boolean {
if (!key || key.startsWith(".")) return false;
if (key.includes(":")) return false;
return /^[A-Za-z0-9._-]+$/.test(key);
@ -224,7 +306,7 @@ function isSafeExampleKey(key) {
// Turn a basename like `stock-portfolio-live` into a title-cased label
// (`Stock Portfolio Live`) so the gallery card has a readable heading
// without forcing every example to ship its own frontmatter.
function humanizeExampleName(key) {
function humanizeExampleName(key: string): string {
return key
.replace(/[-_]+/g, " ")
.replace(/\s+/g, " ")
@ -241,7 +323,7 @@ function humanizeExampleName(key) {
// Used by `/api/skills/:id/example` to resolve a derived id back to its
// on-disk file. Returns null when the key is unsafe; the route checks
// `fs.existsSync` against the returned path before reading.
export function resolveDerivedExamplePath(parentDir, childKey) {
export function resolveDerivedExamplePath(parentDir: string, childKey: string): string | null {
if (!isSafeExampleKey(childKey)) return null;
return path.join(parentDir, "examples", `${childKey}.html`);
}
@ -249,7 +331,7 @@ export function resolveDerivedExamplePath(parentDir, childKey) {
// Split a `<parent>:<child>` synthetic id into its two halves. Returns
// null for non-derived ids so the caller can fall through to the regular
// listing-based lookup.
export function splitDerivedSkillId(id) {
export function splitDerivedSkillId(id: unknown): DerivedSkillIdParts | null {
if (typeof id !== "string") return null;
const idx = id.indexOf(":");
if (idx <= 0 || idx === id.length - 1) return null;
@ -280,7 +362,7 @@ export function splitDerivedSkillId(id) {
//
// Authoring guidance lives in the preamble itself so an agent can pick
// the right form on its own without daemon-side feature detection.
function withSkillRootPreamble(body, dir) {
function withSkillRootPreamble(body: string, dir: string): string {
const referencedFiles = collectReferencedSideFiles(body);
const folder = path.basename(dir);
const skillRootRel = `${SKILLS_CWD_ALIAS}/${folder}`;
@ -318,15 +400,15 @@ function withSkillRootPreamble(body, dir) {
return preamble + body;
}
function collectReferencedSideFiles(body) {
const files = new Set();
function collectReferencedSideFiles(body: string): string[] {
const files = new Set<string>();
const matches = body.matchAll(/\b(?:assets|references)\/[A-Za-z0-9._-]+\b/g);
for (const match of matches) files.add(match[0]);
if (/\bexample\.html\b/.test(body)) files.add("example.html");
return Array.from(files).sort();
}
async function dirHasAttachments(dir) {
async function dirHasAttachments(dir: string): Promise<boolean> {
try {
const entries = await readdir(dir, { withFileTypes: true });
return entries.some(
@ -345,10 +427,10 @@ async function dirHasAttachments(dir) {
// daemon-side allowlist to keep in sync. The compose path checks the
// file actually exists before injecting; missing files fall through
// silently. The frontend can render the requested list verbatim.
function normalizeCraftRequires(value) {
function normalizeCraftRequires(value: unknown): string[] {
if (!Array.isArray(value)) return [];
const seen = new Set();
const out = [];
const seen = new Set<string>();
const out: string[] = [];
for (const v of value) {
if (typeof v !== "string") continue;
const slug = v.trim().toLowerCase();
@ -360,7 +442,7 @@ function normalizeCraftRequires(value) {
return out;
}
function normalizeDefaultFor(value) {
function normalizeDefaultFor(value: unknown): string[] {
if (!value) return [];
if (Array.isArray(value)) return value.map(String);
return [String(value)];
@ -369,7 +451,7 @@ function normalizeDefaultFor(value) {
// Optional `od.fidelity` hint for prototype skills. Only 'wireframe' and
// 'high-fidelity' are meaningful — anything else collapses to null so the
// caller falls back to the form default ('high-fidelity').
function normalizeFidelity(value) {
function normalizeFidelity(value: unknown): "wireframe" | "high-fidelity" | null {
if (value === "wireframe" || value === "high-fidelity") return value;
return null;
}
@ -377,7 +459,7 @@ function normalizeFidelity(value) {
// Coerce truthy / falsy strings ("true", "yes", "false", "no") and booleans
// to a real boolean. Returns null for anything we can't interpret so the
// caller knows to fall back to the form default.
function normalizeBoolHint(value) {
function normalizeBoolHint(value: unknown): boolean | null {
if (typeof value === "boolean") return value;
if (typeof value === "string") {
const v = value.trim().toLowerCase();
@ -391,7 +473,7 @@ function normalizeBoolHint(value) {
// top of the Examples gallery; `true` is treated as priority 1; anything
// missing/unrecognised becomes null so non-featured skills keep their
// natural alphabetical order.
function normalizeFeatured(value) {
function normalizeFeatured(value: unknown): number | null {
if (value === true) return 1;
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string" && value.trim()) {
@ -405,7 +487,7 @@ function normalizeFeatured(value) {
// skill description's first sentence — it's already written in actionable
// language ("Admin / analytics dashboard in a single HTML file…") so it
// serves as a passable starter prompt.
function derivePrompt(data) {
function derivePrompt(data: SkillFrontmatter): string {
const explicit = data.od?.example_prompt;
if (typeof explicit === "string" && explicit.trim()) return explicit.trim();
const desc =
@ -416,7 +498,7 @@ function derivePrompt(data) {
return (firstSentence || collapsed).slice(0, 320);
}
function inferMode(body, description) {
function inferMode(body: unknown, description: unknown): SkillMode {
const hay = `${description ?? ""}\n${body ?? ""}`.toLowerCase();
if (/\bimage|poster|illustration|photography|图片|海报|插画/.test(hay)) return "image";
if (/\bvideo|motion|shortform|animation|视频|动效|短片/.test(hay)) return "video";
@ -428,11 +510,19 @@ function inferMode(body, description) {
return "prototype";
}
const KNOWN_SURFACES = new Set(["web", "image", "video", "audio"]);
function normalizeSurface(value, mode) {
function normalizeMode(value: unknown, body: unknown, description: unknown): SkillMode {
if (
value === "image" || value === "video" || value === "audio" || value === "deck" ||
value === "design-system" || value === "template" || value === "prototype"
) return value;
return inferMode(body, description);
}
const KNOWN_SURFACES = new Set<SkillSurface>(["web", "image", "video", "audio"]);
function normalizeSurface(value: unknown, mode: SkillMode): SkillSurface {
if (typeof value === "string") {
const v = value.trim().toLowerCase();
if (KNOWN_SURFACES.has(v)) return v;
if (KNOWN_SURFACES.has(v as SkillSurface)) return v as SkillSurface;
}
if (mode === "image" || mode === "video" || mode === "audio") return mode;
return "web";
@ -441,7 +531,7 @@ function normalizeSurface(value, mode) {
// Validate platform tag — only desktop / mobile are meaningful for the
// Examples gallery. Falls back to autodetecting "mobile" from descriptions
// so legacy skills sort under the right pill without authoring changes.
function normalizePlatform(value, mode, body, description) {
function normalizePlatform(value: unknown, mode: SkillMode, body: unknown, description: unknown): SkillPlatform {
if (value === "desktop" || value === "mobile") return value;
if (mode !== "prototype") return null;
const hay = `${description ?? ""}\n${body ?? ""}`.toLowerCase();
@ -467,7 +557,7 @@ const KNOWN_SCENARIOS = new Set([
"education",
"personal",
]);
function normalizeScenario(value, body, description) {
function normalizeScenario(value: unknown, body: unknown, description: unknown): string {
if (typeof value === "string") {
const v = value.trim().toLowerCase();
if (v) return v;
@ -500,43 +590,62 @@ void KNOWN_SCENARIOS;
// built-in skill folder shares the same id, to avoid colliding with a
// repo-shipped folder.
export type SkillImportErrorCode =
| "BAD_REQUEST"
| "CONFLICT"
| "NOT_FOUND"
| "INTERNAL_ERROR";
export class SkillImportError extends Error {
constructor(code, message) {
readonly code: SkillImportErrorCode;
constructor(code: SkillImportErrorCode, message: string) {
super(message);
this.code = code;
this.name = "SkillImportError";
}
}
const RESERVED_SLUGS = new Set(["", ".", ".."]);
export function slugifySkillName(name) {
export function slugifySkillName(name: unknown): string {
if (typeof name !== "string") return "";
const lowered = name.trim().toLowerCase();
const cleaned = lowered
.replace(/[^a-z0-9-_]+/g, "-")
.replace(/[^a-z0-9\-_]+/g, "-")
.replace(/^-+|-+$/g, "")
.replace(/-+/g, "-");
if (!cleaned || RESERVED_SLUGS.has(cleaned)) return "";
return cleaned.slice(0, 64);
}
function escapeYamlString(value) {
function escapeYamlString(value: unknown): string {
return String(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"');
}
function buildSkillMarkdown({ name, description, body, triggers }) {
const lines = ["---", `name: ${escapeYamlString(name)}`];
interface BuildSkillMarkdownInput {
name: string;
description: string;
body: string;
triggers: string[];
}
function buildSkillMarkdown({
name,
description,
body,
triggers,
}: BuildSkillMarkdownInput): string {
const lines: string[] = ["---", `name: ${escapeYamlString(name)}`];
if (description && description.trim().length > 0) {
lines.push("description: |");
for (const ln of description.trim().split(/\r?\n/)) {
lines.push(` ${ln}`);
}
}
if (Array.isArray(triggers) && triggers.length > 0) {
if (triggers.length > 0) {
lines.push("triggers:");
for (const t of triggers) {
if (typeof t !== "string") continue;
const trimmed = t.trim();
const trimmed = typeof t === "string" ? t.trim() : "";
if (!trimmed) continue;
lines.push(` - "${escapeYamlString(trimmed)}"`);
}
@ -545,9 +654,28 @@ function buildSkillMarkdown({ name, description, body, triggers }) {
return lines.join("\n");
}
export async function importUserSkill(userSkillsRoot, input) {
const name =
typeof input?.name === "string" ? input.name.trim() : "";
export interface SkillImportInput {
name?: unknown;
description?: unknown;
body?: unknown;
triggers?: unknown;
}
export interface SkillImportResult {
id: string;
slug: string;
dir: string;
}
function isErrnoException(err: unknown): err is NodeJS.ErrnoException {
return Boolean(err) && typeof err === "object" && "code" in (err as object);
}
export async function importUserSkill(
userSkillsRoot: string,
input: SkillImportInput,
): Promise<SkillImportResult> {
const name = typeof input?.name === "string" ? input.name.trim() : "";
const description =
typeof input?.description === "string" ? input.description : "";
const body = typeof input?.body === "string" ? input.body : "";
@ -561,7 +689,7 @@ export async function importUserSkill(userSkillsRoot, input) {
if (!slug) {
throw new SkillImportError(
"BAD_REQUEST",
"skill name must produce a valid slug (a-z, 0-9, dash)"
"skill name must produce a valid slug (a-z, 0-9, dash)",
);
}
const triggersRaw = Array.isArray(input?.triggers) ? input.triggers : [];
@ -578,15 +706,15 @@ export async function importUserSkill(userSkillsRoot, input) {
if (existing) {
throw new SkillImportError(
"CONFLICT",
`a user skill with slug "${slug}" already exists`
`a user skill with slug "${slug}" already exists`,
);
}
} catch (err) {
if (err instanceof SkillImportError) throw err;
if (err && err.code !== "ENOENT") {
if (isErrnoException(err) && err.code !== "ENOENT") {
throw new SkillImportError(
"INTERNAL_ERROR",
`could not check skill dir: ${err.message ?? err}`
`could not check skill dir: ${err.message ?? err}`,
);
}
}
@ -596,7 +724,118 @@ export async function importUserSkill(userSkillsRoot, input) {
return { id: name, slug, dir };
}
export async function deleteUserSkill(userSkillsRoot, id) {
export interface SkillUpdateInput {
name: string;
description?: unknown;
body?: unknown;
triggers?: unknown;
}
// Overwrite (or create-on-demand) a user-owned SKILL.md. The caller is
// expected to have already verified the user's intent — for built-in
// skills this writes a "shadow" copy under USER_SKILLS_DIR/<slug>/ that
// the next listSkills() pass will surface in place of the bundled copy.
// We deliberately do not copy any side files (`assets/`, `references/`)
// — the built-in's body is the one piece the user is editing, and the
// daemon already exposes the original folder via /api/skills/:id/assets/*
// for whatever the frontmatter references.
export async function updateUserSkill(
userSkillsRoot: string,
input: SkillUpdateInput,
): Promise<SkillImportResult> {
const name = typeof input?.name === "string" ? input.name.trim() : "";
if (!name) {
throw new SkillImportError("BAD_REQUEST", "skill name required");
}
const description =
typeof input?.description === "string" ? input.description : "";
const body = typeof input?.body === "string" ? input.body : "";
if (!body || body.trim().length === 0) {
throw new SkillImportError("BAD_REQUEST", "skill body required");
}
const slug = slugifySkillName(name);
if (!slug) {
throw new SkillImportError(
"BAD_REQUEST",
"skill name must produce a valid slug (a-z, 0-9, dash)",
);
}
const triggersRaw = Array.isArray(input?.triggers) ? input.triggers : [];
const triggers = triggersRaw
.map((t) => (typeof t === "string" ? t.trim() : ""))
.filter(Boolean);
await mkdir(userSkillsRoot, { recursive: true });
const dir = path.join(userSkillsRoot, slug);
await mkdir(dir, { recursive: true });
const md = buildSkillMarkdown({ name, description, body, triggers });
await writeFile(path.join(dir, "SKILL.md"), md, "utf8");
return { id: name, slug, dir };
}
export interface SkillFileEntry {
// Path relative to the skill's on-disk directory. Forward-slashes only.
path: string;
// 'file' | 'directory'. We do not surface symlinks or other file types.
kind: "file" | "directory";
// Byte size for files; null for directories.
size: number | null;
}
const SKILL_FILES_MAX_ENTRIES = 500;
const SKILL_FILES_MAX_DEPTH = 6;
// Walk a skill directory and return a flat list of files/folders. Used by
// the Settings → Skills detail panel to render a small file tree next to
// the SKILL.md preview. Skips dotfiles, symlinks, and anything past
// `SKILL_FILES_MAX_DEPTH` so a pathological skill folder cannot stall the
// daemon. The cap on entries protects against large bundled assets folders.
export async function listSkillFiles(skillDir: string): Promise<SkillFileEntry[]> {
const out: SkillFileEntry[] = [];
const seen = new Set<string>();
async function walk(dir: string, depth: number): Promise<void> {
if (depth > SKILL_FILES_MAX_DEPTH) return;
if (out.length >= SKILL_FILES_MAX_ENTRIES) return;
let entries: Dirent[] = [];
try {
entries = await readdir(dir, { withFileTypes: true });
} catch {
return;
}
entries.sort((a, b) => a.name.localeCompare(b.name));
for (const entry of entries) {
if (out.length >= SKILL_FILES_MAX_ENTRIES) return;
if (entry.name.startsWith(".")) continue;
// Refuse symlinks defensively — readdir's withFileTypes already
// returns isSymbolicLink(), but we double-check via the Dirent's
// kind methods to keep this aligned with the read paths elsewhere.
if (entry.isSymbolicLink()) continue;
const abs = path.join(dir, entry.name);
const rel = path.relative(skillDir, abs).split(path.sep).join("/");
if (seen.has(rel)) continue;
seen.add(rel);
if (entry.isDirectory()) {
out.push({ path: rel, kind: "directory", size: null });
await walk(abs, depth + 1);
} else if (entry.isFile()) {
let size: number | null = null;
try {
const s = await stat(abs);
size = s.size;
} catch {
size = null;
}
out.push({ path: rel, kind: "file", size });
}
}
}
await walk(skillDir, 0);
return out;
}
export async function deleteUserSkill(
userSkillsRoot: string,
id: string,
): Promise<void> {
const slug = slugifySkillName(id);
if (!slug) {
throw new SkillImportError("BAD_REQUEST", "invalid skill id");
@ -612,7 +851,7 @@ export async function deleteUserSkill(userSkillsRoot, id) {
try {
await stat(target);
} catch (err) {
if (err && err.code === "ENOENT") {
if (isErrnoException(err) && err.code === "ENOENT") {
throw new SkillImportError("NOT_FOUND", "user skill not found");
}
throw err;

View file

@ -53,6 +53,14 @@ function matchesConnectorToolUseCase(tool: ConnectorToolDetail, useCase: Connect
return tool.curation?.useCases?.includes(useCase) ?? false;
}
function connectorNeedsHydratedDiscovery(definition: ConnectorCatalogDefinition | undefined): boolean {
if (!definition) return true;
if (definition.tools.length === 0) return true;
return definition.toolCount !== undefined && definition.tools.length < definition.toolCount;
}
const AGENT_CONNECTOR_TOOL_HYDRATION_LIMIT = 1000;
export async function listConnectorTools(context: ConnectorToolContext & { useCase?: ConnectorToolUseCase }): Promise<Awaited<ReturnType<ConnectorService['listConnectors']>>> {
const service = context.service ?? connectorService;
// Agent-facing tool discovery sits on the hot path for unattended Orbit
@ -66,11 +74,28 @@ export async function listConnectorTools(context: ConnectorToolContext & { useCa
const connectedStatusIds = Object.entries(service.listConnectorStatuses())
.filter(([, status]) => status.status === 'connected')
.map(([connectorId]) => connectorId);
const hasConnectedConnectorNeedingDiscovery = connectedStatusIds.some((connectorId) => {
const connectedConnectorIdsNeedingDiscovery = connectedStatusIds.filter((connectorId) => {
const fastDefinition = fastDefinitionsById.get(connectorId);
return !fastDefinition || fastDefinition.tools.length === 0;
return connectorNeedsHydratedDiscovery(fastDefinition);
});
const definitions = hasConnectedConnectorNeedingDiscovery ? await service.listDefinitions() : fastDefinitions;
let definitions = fastDefinitions;
if (connectedConnectorIdsNeedingDiscovery.length > 0) {
const targetedDefinitions = await Promise.all(connectedConnectorIdsNeedingDiscovery.map(async (connectorId) => {
const fastDefinition = fastDefinitionsById.get(connectorId);
return fastDefinition
? await service.getPreviewDefinition(connectorId, { toolsLimit: AGENT_CONNECTOR_TOOL_HYDRATION_LIMIT })
: await service.getHydratedDefinition(connectorId);
}));
const targetedDefinitionsById = new Map(
targetedDefinitions
.filter((definition): definition is ConnectorCatalogDefinition => definition !== undefined)
.map((definition) => [definition.id, definition]),
);
definitions = fastDefinitions.map((definition) => targetedDefinitionsById.get(definition.id) ?? definition);
for (const definition of targetedDefinitionsById.values()) {
if (!fastDefinitionsById.has(definition.id)) definitions.push(definition);
}
}
const entries = definitions.map((definition) => {
const detail = connectorDefinitionToDetail(definition);
const status = service.getStatus(definition);

View file

@ -1,8 +1,9 @@
// @ts-nocheck
import assert from 'node:assert/strict';
import { EventEmitter } from 'node:events';
import { PassThrough } from 'node:stream';
import path from 'node:path';
import { test } from 'vitest';
import { buildAcpSessionNewParams } from '../src/acp.js';
import { attachAcpSession, buildAcpSessionNewParams } from '../src/acp.js';
test('ACP session params do not require MCP servers by default', () => {
assert.deepEqual(buildAcpSessionNewParams('/tmp/od-project'), {
@ -42,7 +43,53 @@ test('ACP session params preserve caller-provided type and env fields', () => {
];
const result = buildAcpSessionNewParams('/tmp/od-project', { mcpServers });
assert.equal(result.mcpServers[0].type, 'http');
assert.equal(result.mcpServers[0].name, 'http-server');
assert.deepEqual(result.mcpServers[0].env, [{ key: 'TOKEN', value: 'secret' }]);
const server = result.mcpServers[0];
assert.ok(server);
assert.equal(server.type, 'http');
assert.equal(server.name, 'http-server');
assert.deepEqual(server.env, [{ key: 'TOKEN', value: 'secret' }]);
});
test('attachAcpSession exposes abort and sends session cancel after session creation', () => {
const child = new FakeAcpChild();
const writes: string[] = [];
child.stdin.on('data', (chunk) => writes.push(String(chunk)));
const session = attachAcpSession({
child: child as never,
prompt: 'hello',
cwd: '/tmp/od-project',
model: null,
mcpServers: [],
send: () => {},
});
child.stdout.write(`${JSON.stringify({ id: 1, result: {} })}\n`);
child.stdout.write(`${JSON.stringify({ id: 2, result: { sessionId: 'session-1' } })}\n`);
assert.equal(typeof session.abort, 'function');
session.abort();
session.abort();
const parsed = writes
.join('')
.trim()
.split('\n')
.filter(Boolean)
.map((line) => JSON.parse(line));
const cancelRequests = parsed.filter((entry) => entry.method === 'session/cancel');
assert.equal(cancelRequests.length, 1);
assert.deepEqual(cancelRequests[0].params, { sessionId: 'session-1' });
});
class FakeAcpChild extends EventEmitter {
stdin = new PassThrough();
stdout = new PassThrough();
stderr = new PassThrough();
killed = false;
kill() {
this.killed = true;
return true;
}
}

View file

@ -1,4 +1,3 @@
// @ts-nocheck
import { afterEach, test } from 'vitest';
import assert from 'node:assert/strict';
import {
@ -22,21 +21,48 @@ import {
} from '../src/agents.js';
import { createLiveArtifactsMcpTools, handleLiveArtifactsMcpRequest } from '../src/mcp-live-artifacts-server.js';
const codex = AGENT_DEFS.find((agent) => agent.id === 'codex');
const hermes = AGENT_DEFS.find((agent) => agent.id === 'hermes');
const kimi = AGENT_DEFS.find((agent) => agent.id === 'kimi');
type TestAgentDef = (typeof AGENT_DEFS)[number];
const copilot = AGENT_DEFS.find((agent) => agent.id === 'copilot');
const cursorAgent = AGENT_DEFS.find((agent) => agent.id === 'cursor-agent');
const kiro = AGENT_DEFS.find((agent) => agent.id === 'kiro');
const kilo = AGENT_DEFS.find((agent) => agent.id === 'kilo');
const vibe = AGENT_DEFS.find((agent) => agent.id === 'vibe');
const claude = AGENT_DEFS.find((agent) => agent.id === 'claude');
const devin = AGENT_DEFS.find((agent) => agent.id === 'devin');
const pi = AGENT_DEFS.find((agent) => agent.id === 'pi');
const deepseek = AGENT_DEFS.find((agent) => agent.id === 'deepseek');
const gemini = AGENT_DEFS.find((agent) => agent.id === 'gemini');
const qoder = AGENT_DEFS.find((agent) => agent.id === 'qoder');
function requireAgent(id: string): TestAgentDef {
const agent = AGENT_DEFS.find((candidate) => candidate.id === id);
assert.ok(agent, `missing agent definition for ${id}`);
return agent;
}
function minimalAgentDef(partial: Pick<TestAgentDef, 'bin'> & Partial<TestAgentDef>): TestAgentDef {
const { bin, ...rest } = partial;
return {
id: partial.id ?? `test-${bin}`,
name: partial.name ?? bin,
bin,
versionArgs: partial.versionArgs ?? ['--version'],
fallbackModels: partial.fallbackModels ?? [{ id: 'default', label: 'Default' }],
buildArgs: partial.buildArgs ?? (() => []),
streamFormat: partial.streamFormat ?? 'plain',
...rest,
} as unknown as TestAgentDef;
}
const codex = requireAgent('codex');
const hermes = requireAgent('hermes');
const kimi = requireAgent('kimi');
const copilot = requireAgent('copilot');
const cursorAgent = requireAgent('cursor-agent');
const kiro = requireAgent('kiro');
const kilo = requireAgent('kilo');
const vibe = requireAgent('vibe');
const claude = requireAgent('claude');
const devin = requireAgent('devin');
const pi = requireAgent('pi');
const deepseek = requireAgent('deepseek');
const gemini = requireAgent('gemini');
const qoder = requireAgent('qoder');
const deepseekMaxPromptArgBytes = deepseek.maxPromptArgBytes;
assert.ok(
deepseekMaxPromptArgBytes !== undefined,
'deepseek must define maxPromptArgBytes for argv budget tests',
);
const originalDisablePlugins = process.env.OD_CODEX_DISABLE_PLUGINS;
const originalPath = process.env.PATH;
const originalHome = process.env.HOME;
@ -97,7 +123,7 @@ afterEach(() => {
}
});
function withPlatform(platform, run) {
function withPlatform<T>(platform: NodeJS.Platform, run: () => T): T {
Object.defineProperty(process, 'platform', {
configurable: true,
value: platform,
@ -176,6 +202,7 @@ test('codex model picker includes current OpenAI choices in priority order', asy
];
assert.deepEqual(codex.fallbackModels.map((m) => m.id), expectedModels);
assert.ok(codex.reasoningOptions, 'codex must define reasoningOptions');
assert.deepEqual(codex.reasoningOptions.map((o) => o.id), [
'default',
'none',
@ -214,7 +241,7 @@ test('codex model picker includes current OpenAI choices in priority order', asy
assert.ok(detected);
assert.equal(detected.available, true);
assert.equal(detected.version, 'codex 1.0.0');
assert.deepEqual(detected.models.map((m) => m.id), expectedModels);
assert.deepEqual(detected.models.map((m: { id: string }) => m.id), expectedModels);
} finally {
rmSync(dir, { recursive: true, force: true });
}
@ -265,7 +292,7 @@ test('codex args pass valid extraAllowedDirs with repeatable --add-dir flags', (
const args = codex.buildArgs(
'',
[],
['/repo/skills', '', null, '/tmp/codex/generated_images', undefined],
['/repo/skills', '', null, '/tmp/codex/generated_images', undefined] as unknown as string[],
{},
{ cwd: '/tmp/od-project' },
);
@ -309,7 +336,7 @@ test('live artifact MCP discovery can use daemon-resolved CLI command', () => {
buildLiveArtifactsMcpServersForAgent(hermes, {
command: process.execPath,
argsPrefix: ['/workspace/apps/daemon/dist/cli.js'],
}),
} as unknown as Parameters<typeof buildLiveArtifactsMcpServersForAgent>[1]),
[
{
name: 'open-design-live-artifacts',
@ -338,11 +365,11 @@ test('MCP-capable agents can discover equivalent live artifact and connector too
assert.equal(tool.inputSchema.type, 'object');
}
const initialized = await handleLiveArtifactsMcpRequest({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} });
const initialized = await handleLiveArtifactsMcpRequest({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} }) as { result: { serverInfo: { name: string }; capabilities: unknown } };
assert.equal(initialized.result.serverInfo.name, 'open-design-live-artifacts');
assert.deepEqual(initialized.result.capabilities, { tools: {} });
const listed = await handleLiveArtifactsMcpRequest({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} });
const listed = await handleLiveArtifactsMcpRequest({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} }) as { result: { tools: Array<{ name: string }> } };
assert.deepEqual(listed.result.tools.map((tool) => tool.name), tools.map((tool) => tool.name));
const createTool = tools.find((tool) => tool.name === 'live_artifacts_create')!;
@ -359,7 +386,7 @@ test('MCP-capable agents can discover equivalent live artifact and connector too
test('live artifact MCP connector list forwards daily digest use case to daemon tools', async () => {
process.env.OD_DAEMON_URL = 'http://127.0.0.1:17456/base';
process.env.OD_TOOL_TOKEN = 'test-tool-token';
const calls = [];
const calls: Array<{ url: string; init: RequestInit | undefined }> = [];
globalThis.fetch = async (url, init) => {
calls.push({ url: String(url), init });
return new Response(JSON.stringify({ connectors: [] }), { status: 200 });
@ -370,17 +397,19 @@ test('live artifact MCP connector list forwards daily digest use case to daemon
id: 5,
method: 'tools/call',
params: { name: 'connectors_list', arguments: { useCase: 'personal_daily_digest' } },
});
}) as { error?: unknown };
assert.equal(response.error, undefined);
assert.equal(calls.length, 1);
assert.equal(calls[0].url, 'http://127.0.0.1:17456/base/api/tools/connectors/list?useCase=personal_daily_digest');
const call = calls[0];
assert.ok(call);
assert.equal(call.url, 'http://127.0.0.1:17456/base/api/tools/connectors/list?useCase=personal_daily_digest');
});
test('live artifact MCP create forwards input and artifact payload fields to daemon tools', async () => {
process.env.OD_DAEMON_URL = 'http://127.0.0.1:17456';
process.env.OD_TOOL_TOKEN = 'test-tool-token';
const calls = [];
const calls: Array<{ url: string; init: RequestInit | undefined }> = [];
globalThis.fetch = async (url, init) => {
calls.push({ url: String(url), init });
return new Response(JSON.stringify({ artifact: { id: 'artifact-1' } }), { status: 200 });
@ -394,18 +423,21 @@ test('live artifact MCP create forwards input and artifact payload fields to dae
id: 3,
method: 'tools/call',
params: { name: 'live_artifacts_create', arguments: { input, templateHtml, provenanceJson } },
});
}) as { error?: unknown };
assert.equal(response.error, undefined);
assert.equal(calls.length, 1);
assert.equal(calls[0].url, 'http://127.0.0.1:17456/api/tools/live-artifacts/create');
assert.deepEqual(JSON.parse(calls[0].init.body), { input, templateHtml, provenanceJson });
const call = calls[0];
assert.ok(call);
assert.ok(call.init);
assert.equal(call.url, 'http://127.0.0.1:17456/api/tools/live-artifacts/create');
assert.deepEqual(JSON.parse(call.init.body as string), { input, templateHtml, provenanceJson });
});
test('live artifact MCP update preserves nested input and artifact payload fields', async () => {
process.env.OD_DAEMON_URL = 'http://127.0.0.1:17456';
process.env.OD_TOOL_TOKEN = 'test-tool-token';
const calls = [];
const calls: Array<{ url: string; init: RequestInit | undefined }> = [];
globalThis.fetch = async (url, init) => {
calls.push({ url: String(url), init });
return new Response(JSON.stringify({ artifact: { id: 'artifact-1', title: 'Updated' } }), { status: 200 });
@ -419,12 +451,15 @@ test('live artifact MCP update preserves nested input and artifact payload field
id: 4,
method: 'tools/call',
params: { name: 'live_artifacts_update', arguments: { artifactId: 'artifact-1', input, templateHtml, provenanceJson } },
});
}) as { error?: unknown };
assert.equal(response.error, undefined);
assert.equal(calls.length, 1);
assert.equal(calls[0].url, 'http://127.0.0.1:17456/api/tools/live-artifacts/update');
assert.deepEqual(JSON.parse(calls[0].init.body), { artifactId: 'artifact-1', input, templateHtml, provenanceJson });
const call = calls[0];
assert.ok(call);
assert.ok(call.init);
assert.equal(call.url, 'http://127.0.0.1:17456/api/tools/live-artifacts/update');
assert.deepEqual(JSON.parse(call.init.body as string), { artifactId: 'artifact-1', input, templateHtml, provenanceJson });
});
test('cursor-agent args deliver prompts via stdin without passing a literal dash prompt', () => {
@ -504,7 +539,7 @@ test('copilot drops empty / non-string entries from extraAllowedDirs without rei
const args = copilot.buildArgs(
prompt,
[],
['', null, '/tmp/od-skills', undefined],
['', null, '/tmp/od-skills', undefined] as unknown as string[],
{},
);
assert.ok(!args.includes('-p'));
@ -688,7 +723,7 @@ test('qoder args use non-interactive print mode with cwd, model, and add-dir', (
[
'/repo/skills',
'',
null,
null as unknown as string,
'./relative-skills',
'relative-design-systems',
'/repo/design-systems',
@ -736,9 +771,9 @@ test('qoder args omit default model and cwd when absent', () => {
test('qoder args omit empty, non-string, and relative add-dir entries', () => {
const args = qoder.buildArgs('', [], [
'',
null,
undefined,
42,
null as unknown as string,
undefined as unknown as string,
42 as unknown as string,
'./skills',
'design-systems',
]);
@ -749,13 +784,13 @@ test('qoder args omit empty, non-string, and relative add-dir entries', () => {
test('qoder args omit empty, non-string, and relative image attachment entries', () => {
const args = qoder.buildArgs('', [
'',
null,
undefined,
42,
null as unknown as string,
undefined as unknown as string,
42 as unknown as string,
'./uploads/logo.png',
'uploads/hero.png',
'/tmp/uploads/logo.png',
]);
], []);
assert.deepEqual(
args.filter((arg) => arg === '--attachment').length,
@ -779,7 +814,10 @@ test('qoder adapter inherits QODER_PERSONAL_ACCESS_TOKEN from daemon env', () =>
});
test('qoder adapter does not define static secret env', () => {
assert.equal(qoder.env?.QODER_PERSONAL_ACCESS_TOKEN, undefined);
assert.equal(
(qoder as TestAgentDef & { env?: Record<string, string> }).env?.QODER_PERSONAL_ACCESS_TOKEN,
undefined,
);
});
test('detectAgents keeps qoder unavailable with fallback metadata when qodercli is missing', async () => {
@ -794,7 +832,7 @@ test('detectAgents keeps qoder unavailable with fallback metadata when qodercli
assert.ok(detected);
assert.equal(detected.available, false);
assert.equal(detected.bin, 'qodercli');
assert.deepEqual(detected.models.map((m) => m.id), [
assert.deepEqual(detected.models.map((m: { id: string }) => m.id), [
'default',
'lite',
'efficient',
@ -810,13 +848,16 @@ test('detectAgents keeps qoder unavailable with fallback metadata when qodercli
test('kiro fetchModels falls back to fallbackModels when detection fails', async () => {
// fetchModels rejects when the binary doesn't exist; the daemon's
// probe() catches this and uses fallbackModels instead.
assert.ok(kiro.fetchModels, 'kiro must define fetchModels');
const result = await kiro
.fetchModels('/nonexistent/kiro-cli')
.fetchModels('/nonexistent/kiro-cli', {})
.catch(() => null);
assert.equal(result, null);
assert.ok(Array.isArray(kiro.fallbackModels));
assert.equal(kiro.fallbackModels[0].id, 'default');
const fallbackModel = kiro.fallbackModels[0];
assert.ok(fallbackModel);
assert.equal(fallbackModel.id, 'default');
});
test('kilo args use acp subcommand for json-rpc streaming', () => {
@ -827,11 +868,14 @@ test('kilo args use acp subcommand for json-rpc streaming', () => {
});
test('kilo fetchModels falls back to fallbackModels when detection fails', async () => {
const result = await kilo.fetchModels('/nonexistent/kilo').catch(() => null);
assert.ok(kilo.fetchModels, 'kilo must define fetchModels');
const result = await kilo.fetchModels('/nonexistent/kilo', {}).catch(() => null);
assert.equal(result, null);
assert.ok(Array.isArray(kilo.fallbackModels));
assert.equal(kilo.fallbackModels[0].id, 'default');
const fallbackModel = kilo.fallbackModels[0];
assert.ok(fallbackModel);
assert.equal(fallbackModel.id, 'default');
assert.equal(kilo.fallbackModels.length, 1);
});
@ -841,7 +885,7 @@ test('kilo fetchModels falls back to fallbackModels when detection fails', async
// flag is what the codex CLI (and ultimately OpenAI) actually sees.
test('codex buildArgs clamps reasoning effort per model', () => {
const cases = [
const cases: Array<[string | undefined, string, string]> = [
// [model, reasoning, expected wire-level effort]
// gpt-5.5 family (and unknown / 'default' which we treat as 5.5):
// minimal -> low, others pass through.
@ -873,7 +917,7 @@ test('codex buildArgs clamps reasoning effort per model', () => {
'',
[],
[],
{ model, reasoning },
{ ...(model === undefined ? {} : { model }), reasoning },
{ cwd: '/tmp/od-project' },
);
assert.ok(
@ -967,7 +1011,7 @@ test('claude buildArgs passes --add-dir when dirs are supplied (issue #430, prob
});
test('claude buildArgs drops empty / null dirs but keeps valid ones (issue #430 edge case)', () => {
const args = claude.buildArgs('', [], ['', null, '/repo/skills', undefined], {});
const args = claude.buildArgs('', [], ['', null, '/repo/skills', undefined] as unknown as string[], {});
const addDirIndex = args.indexOf('--add-dir');
assert.ok(addDirIndex >= 0, '--add-dir should survive filter');
@ -977,8 +1021,8 @@ test('claude buildArgs drops empty / null dirs but keeps valid ones (issue #430
assert.equal(args.filter((a) => a === '--add-dir').length, 1);
// Should NOT have null / undefined / '' sneaking into argv.
assert.equal(args.includes(''), false);
assert.equal(args.includes(null), false);
assert.equal(args.includes(undefined), false);
assert.equal(args.includes(null as unknown as string), false);
assert.equal(args.includes(undefined as unknown as string), false);
});
test('claude helpArgs probes the -p subcommand where --add-dir lives (issue #430 root cause)', () => {
@ -1027,10 +1071,10 @@ fsTest(
process.env.OD_AGENT_HOME = dir;
process.env.PATH = dir;
const resolved = resolveAgentExecutable({
const resolved = resolveAgentExecutable(minimalAgentDef({
bin: 'claude',
fallbackBins: ['openclaude'],
});
}));
assert.equal(resolved, join(dir, 'claude'));
} finally {
rmSync(dir, { recursive: true, force: true });
@ -1049,10 +1093,10 @@ fsTest(
process.env.OD_AGENT_HOME = dir;
process.env.PATH = dir;
const resolved = resolveAgentExecutable({
const resolved = resolveAgentExecutable(minimalAgentDef({
bin: 'claude',
fallbackBins: ['openclaude'],
});
}));
assert.equal(resolved, join(dir, 'openclaude'));
} finally {
rmSync(dir, { recursive: true, force: true });
@ -1068,10 +1112,10 @@ fsTest(
process.env.OD_AGENT_HOME = dir;
process.env.PATH = dir;
const resolved = resolveAgentExecutable({
const resolved = resolveAgentExecutable(minimalAgentDef({
bin: 'claude',
fallbackBins: ['openclaude'],
});
}));
assert.equal(resolved, null);
} finally {
rmSync(dir, { recursive: true, force: true });
@ -1100,9 +1144,9 @@ fsTest(
process.env.OD_AGENT_HOME = home;
process.env.PATH = '/usr/bin:/bin';
const resolved = resolveAgentExecutable({
const resolved = resolveAgentExecutable(minimalAgentDef({
bin: 'codex',
});
}));
assert.equal(resolved, join(dir, 'codex'));
} finally {
rmSync(home, { recursive: true, force: true });
@ -1122,7 +1166,7 @@ fsTest(
chmodSync(join(dir, 'codex'), 0o755);
process.env.PATH = dir;
const resolved = resolveAgentExecutable({ bin: 'codex' });
const resolved = resolveAgentExecutable(minimalAgentDef({ bin: 'codex' }));
assert.equal(resolved, join(dir, 'codex'));
} finally {
rmSync(dir, { recursive: true, force: true });
@ -1150,7 +1194,7 @@ fsTest(
// `~/.npm-global/bin`, no `/opt/homebrew/bin`, nothing user-side.
process.env.PATH = '/usr/bin:/bin';
const resolved = resolveAgentExecutable({ bin: 'gemini' });
const resolved = resolveAgentExecutable(minimalAgentDef({ bin: 'gemini' }));
assert.equal(resolved, join(dir, 'gemini'));
} finally {
rmSync(home, { recursive: true, force: true });
@ -1172,7 +1216,7 @@ fsTest(
process.env.OD_AGENT_HOME = home;
process.env.PATH = '/usr/bin:/bin';
const resolved = resolveAgentExecutable({ bin: 'gemini' });
const resolved = resolveAgentExecutable(minimalAgentDef({ bin: 'gemini' }));
assert.equal(resolved, join(dir, 'gemini'));
} finally {
rmSync(home, { recursive: true, force: true });
@ -1192,7 +1236,7 @@ fsTest(
process.env.OD_AGENT_HOME = home;
process.env.PATH = '/usr/bin:/bin';
const resolved = resolveAgentExecutable({ bin: 'vp-cli-probe' });
const resolved = resolveAgentExecutable(minimalAgentDef({ bin: 'vp-cli-probe' }));
assert.equal(resolved, join(dir, 'vp-cli-probe'));
} finally {
rmSync(home, { recursive: true, force: true });
@ -1212,7 +1256,7 @@ fsTest(
process.env.PATH = '/usr/bin:/bin';
process.env.VP_HOME = vpHome;
const resolved = resolveAgentExecutable({ bin: 'vp-cli-probe' });
const resolved = resolveAgentExecutable(minimalAgentDef({ bin: 'vp-cli-probe' }));
assert.equal(resolved, join(dir, 'vp-cli-probe'));
} finally {
rmSync(vpHome, { recursive: true, force: true });
@ -1245,7 +1289,7 @@ fsTest(
process.env.PATH = '/usr/bin:/bin';
process.env.NPM_CONFIG_PREFIX = realPrefix;
const resolved = resolveAgentExecutable({ bin: 'gemini' });
const resolved = resolveAgentExecutable(minimalAgentDef({ bin: 'gemini' }));
assert.equal(
resolved,
null,
@ -1279,7 +1323,7 @@ fsTest(
process.env.PATH = '/usr/bin:/bin';
process.env.VP_HOME = realVpHome;
const resolved = resolveAgentExecutable({ bin: 'vp-cli-probe' });
const resolved = resolveAgentExecutable(minimalAgentDef({ bin: 'vp-cli-probe' }));
assert.equal(
resolved,
null,
@ -1334,13 +1378,13 @@ test('deepseek args omit --model when model is "default"', () => {
// the field so removing it can't silently regress the guard.
test('deepseek declares a conservative argv-byte budget for the prompt', () => {
assert.equal(
typeof deepseek.maxPromptArgBytes,
typeof deepseekMaxPromptArgBytes,
'number',
'deepseek must set maxPromptArgBytes so the spawn path can pre-flight oversized prompts before hitting CreateProcess / E2BIG',
);
assert.ok(
deepseek.maxPromptArgBytes > 0 && deepseek.maxPromptArgBytes < 32_768,
`deepseek.maxPromptArgBytes must stay strictly under the Windows CreateProcess limit (~32 KB); got ${deepseek.maxPromptArgBytes}`,
deepseekMaxPromptArgBytes > 0 && deepseekMaxPromptArgBytes < 32_768,
`deepseekMaxPromptArgBytes must stay strictly under the Windows CreateProcess limit (~32 KB); got ${deepseekMaxPromptArgBytes}`,
);
});
@ -1354,12 +1398,12 @@ test('deepseek declares a conservative argv-byte budget for the prompt', () => {
// budget drift over the Windows limit fails this test before any
// real spawn would surface a generic ENAMETOOLONG / E2BIG.
test('checkPromptArgvBudget flags oversized DeepSeek prompts and lets short prompts through', () => {
const oversized = 'x'.repeat(deepseek.maxPromptArgBytes + 1);
const oversized = 'x'.repeat(deepseekMaxPromptArgBytes + 1);
const flagged = checkPromptArgvBudget(deepseek, oversized);
assert.ok(flagged, 'oversized prompts must trip the argv-byte guard');
assert.equal(flagged.code, 'AGENT_PROMPT_TOO_LARGE');
assert.equal(flagged.limit, deepseek.maxPromptArgBytes);
assert.equal(flagged.bytes, deepseek.maxPromptArgBytes + 1);
assert.equal(flagged.limit, deepseekMaxPromptArgBytes);
assert.equal(flagged.bytes, deepseekMaxPromptArgBytes + 1);
assert.match(flagged.message, /DeepSeek/);
assert.match(flagged.message, /command-line argument/);
assert.match(flagged.message, /stdin support/);
@ -1370,14 +1414,14 @@ test('checkPromptArgvBudget flags oversized DeepSeek prompts and lets short prom
// The exact-budget edge: a prompt right at the limit must pass; the
// guard fires only when the byte count strictly exceeds the budget.
const atLimit = 'x'.repeat(deepseek.maxPromptArgBytes);
const atLimit = 'x'.repeat(deepseekMaxPromptArgBytes);
assert.equal(checkPromptArgvBudget(deepseek, atLimit), null);
// A multi-byte UTF-8 prompt (e.g. CJK characters) is measured in
// bytes, not code points — pin that so a 3-byte-per-char prompt
// can't sneak past a code-point-based regression of the helper.
const cjkOversized = '汉'.repeat(
Math.ceil(deepseek.maxPromptArgBytes / 3) + 1,
Math.ceil(deepseekMaxPromptArgBytes / 3) + 1,
);
const cjkFlagged = checkPromptArgvBudget(deepseek, cjkOversized);
assert.ok(cjkFlagged, 'byte-counted UTF-8 prompts must also trip the guard');
@ -1409,7 +1453,7 @@ test('checkPromptArgvBudget is a no-op for adapters without maxPromptArgBytes',
test('checkWindowsCmdShimCommandLineBudget flags quote-heavy prompts that expand past CreateProcess limit', () => {
// Prompt is *under* the raw byte budget, but ~entirely `"` chars so
// cmd.exe's quote-doubling roughly doubles its command-line cost.
const quoteHeavyPromptLength = deepseek.maxPromptArgBytes - 100;
const quoteHeavyPromptLength = deepseekMaxPromptArgBytes - 100;
const quoteHeavyPrompt = '"'.repeat(quoteHeavyPromptLength);
// Sanity: the raw-byte guard must let this through, otherwise the new
@ -1435,9 +1479,11 @@ test('checkWindowsCmdShimCommandLineBudget flags quote-heavy prompts that expand
'quote-heavy prompt that doubles past the CreateProcess cap must trip the cmd-shim guard',
);
assert.equal(flagged.code, 'AGENT_PROMPT_TOO_LARGE');
const commandLineLength = flagged.commandLineLength;
assert.ok(commandLineLength !== undefined);
assert.ok(
flagged.commandLineLength > flagged.limit,
`commandLineLength (${flagged.commandLineLength}) must exceed limit (${flagged.limit})`,
commandLineLength > flagged.limit,
`commandLineLength (${commandLineLength}) must exceed limit (${flagged.limit})`,
);
assert.ok(
flagged.limit < 32_768,
@ -1556,7 +1602,7 @@ test('checkWindowsCmdShimCommandLineBudget no-ops when resolvedBin is null or ad
test('checkWindowsDirectExeCommandLineBudget flags quote-heavy prompts on a direct .exe resolution', () => {
// Prompt is *under* the raw byte budget, but ~entirely `"` chars so
// libuv's `\"` escaping roughly doubles its command-line cost.
const quoteHeavyPromptLength = deepseek.maxPromptArgBytes - 100;
const quoteHeavyPromptLength = deepseekMaxPromptArgBytes - 100;
const quoteHeavyPrompt = '"'.repeat(quoteHeavyPromptLength);
// Sanity: the raw-byte guard must let this through, otherwise the
@ -1583,9 +1629,11 @@ test('checkWindowsDirectExeCommandLineBudget flags quote-heavy prompts on a dire
'quote-heavy prompt that expands past the CreateProcess cap on a direct .exe spawn must trip the guard',
);
assert.equal(flagged.code, 'AGENT_PROMPT_TOO_LARGE');
const commandLineLength = flagged.commandLineLength;
assert.ok(commandLineLength !== undefined);
assert.ok(
flagged.commandLineLength > flagged.limit,
`commandLineLength (${flagged.commandLineLength}) must exceed limit (${flagged.limit})`,
commandLineLength > flagged.limit,
`commandLineLength (${commandLineLength}) must exceed limit (${flagged.limit})`,
);
assert.ok(
flagged.limit < 32_768,
@ -1613,7 +1661,7 @@ test('checkWindowsDirectExeCommandLineBudget no-ops on .cmd / .bat resolutions a
// skip them so an oversized prompt on a `.cmd` install doesn't trip
// both guards (and double-emit an SSE error).
const args = deepseek.buildArgs(
'"'.repeat(deepseek.maxPromptArgBytes - 100),
'"'.repeat(deepseekMaxPromptArgBytes - 100),
[],
[],
{},
@ -1681,7 +1729,7 @@ test('checkWindowsDirectExeCommandLineBudget no-ops when resolvedBin is null/emp
// SSE error events back to back. Pin both branches with a quote-heavy
// prompt that's over the kernel cap under either quoting rule.
test('cmd-shim and direct-exe guards are mutually exclusive on a single resolution', () => {
const quoteHeavy = '"'.repeat(deepseek.maxPromptArgBytes - 100);
const quoteHeavy = '"'.repeat(deepseekMaxPromptArgBytes - 100);
const args = deepseek.buildArgs(quoteHeavy, [], [], {});
const cmdPath = 'C:\\Users\\Tester\\AppData\\Roaming\\npm\\deepseek.cmd';
@ -1708,9 +1756,10 @@ test('deepseek entry does not advertise deepseek-tui as a fallback bin', () => {
// the first /api/chat run fail. Pin the absence so the fallback can't
// drift back without an accompanying buildArgs branch + test.
assert.equal(
Array.isArray(deepseek.fallbackBins) && deepseek.fallbackBins.length > 0,
Array.isArray((deepseek as TestAgentDef & { fallbackBins?: string[] }).fallbackBins)
&& ((deepseek as TestAgentDef & { fallbackBins?: string[] }).fallbackBins?.length ?? 0) > 0,
false,
`deepseek must not declare fallbackBins until the deepseek-tui-only invocation is implemented and tested; got ${JSON.stringify(deepseek.fallbackBins)}`,
`deepseek must not declare fallbackBins until the deepseek-tui-only invocation is implemented and tested; got ${JSON.stringify((deepseek as TestAgentDef & { fallbackBins?: string[] }).fallbackBins)}`,
);
});
@ -1724,13 +1773,16 @@ test('vibe args use empty array for acp-json-rpc streaming', () => {
test('vibe fetchModels falls back to fallbackModels when detection fails', async () => {
// fetchModels rejects when the binary doesn't exist; the daemon's
// probe() catches this and uses fallbackModels instead.
assert.ok(vibe.fetchModels, 'vibe must define fetchModels');
const result = await vibe
.fetchModels('/nonexistent/vibe-acp')
.fetchModels('/nonexistent/vibe-acp', {})
.catch(() => null);
assert.equal(result, null);
assert.ok(Array.isArray(vibe.fallbackModels));
assert.equal(vibe.fallbackModels[0].id, 'default');
const fallbackModel = vibe.fallbackModels[0];
assert.ok(fallbackModel);
assert.equal(fallbackModel.id, 'default');
});
// Issue #398: Claude Code prefers ANTHROPIC_API_KEY over `claude login`
@ -1789,7 +1841,7 @@ test('resolveAgentExecutable prefers a configured CODEX_BIN override over PATH r
process.env.OD_AGENT_HOME = dir;
const resolved = resolveAgentExecutable(
{ id: 'codex', bin: 'codex' },
minimalAgentDef({ id: 'codex', bin: 'codex' }),
{ CODEX_BIN: configured },
);
@ -1800,7 +1852,7 @@ test('resolveAgentExecutable prefers a configured CODEX_BIN override over PATH r
});
test('resolveAgentExecutable supports configured binary overrides for non-Codex adapters', () => {
const cases = [
const cases: Array<[string, string, string]> = [
['claude', 'claude', 'CLAUDE_BIN'],
['gemini', 'gemini', 'GEMINI_BIN'],
['opencode', 'opencode', 'OPENCODE_BIN'],
@ -1821,7 +1873,7 @@ test('resolveAgentExecutable supports configured binary overrides for non-Codex
chmodSync(configured, 0o755);
const resolved = resolveAgentExecutable(
{ id, bin: binName },
minimalAgentDef({ id, bin: binName }),
{ [envKey]: configured },
);
@ -1844,7 +1896,7 @@ test('resolveAgentExecutable ignores relative CODEX_BIN overrides', () => {
process.env.OD_AGENT_HOME = dir;
const resolved = resolveAgentExecutable(
{ id: 'codex', bin: 'codex' },
minimalAgentDef({ id: 'codex', bin: 'codex' }),
{ CODEX_BIN: configured },
);
@ -1867,12 +1919,12 @@ test('resolveAgentExecutable ignores configured binary overrides that are not ex
process.env.OD_AGENT_HOME = dir;
assert.equal(
resolveAgentExecutable({ id: 'codex', bin: 'codex' }, { CODEX_BIN: directoryOverride }),
resolveAgentExecutable(minimalAgentDef({ id: 'codex', bin: 'codex' }), { CODEX_BIN: directoryOverride }),
null,
);
if (process.platform !== 'win32') {
assert.equal(
resolveAgentExecutable({ id: 'codex', bin: 'codex' }, { CODEX_BIN: fileOverride }),
resolveAgentExecutable(minimalAgentDef({ id: 'codex', bin: 'codex' }), { CODEX_BIN: fileOverride }),
null,
);
}
@ -1894,7 +1946,7 @@ test('resolveAgentExecutable ignores Windows CODEX_BIN overrides without executa
const resolved = withPlatform('win32', () =>
resolveAgentExecutable(
{ id: 'codex', bin: 'codex' },
minimalAgentDef({ id: 'codex', bin: 'codex' }),
{ CODEX_BIN: invalidOverride },
),
);
@ -1916,7 +1968,7 @@ test('resolveAgentExecutable accepts Windows CODEX_BIN overrides with executable
const resolved = withPlatform('win32', () =>
resolveAgentExecutable(
{ id: 'codex', bin: 'codex' },
minimalAgentDef({ id: 'codex', bin: 'codex' }),
{ CODEX_BIN: configured },
),
);

View file

@ -387,6 +387,61 @@ setInterval(() => {}, 1000);
});
});
describe('daemon run creation during shutdown', () => {
it('rejects new run creation while shutdown cleanup is still in flight', async () => {
const previousGrace = process.env.OD_CHAT_RUN_SHUTDOWN_GRACE_MS;
process.env.OD_CHAT_RUN_SHUTDOWN_GRACE_MS = '100';
const started = await startServer({ port: 0, returnServer: true }) as {
url: string;
server: http.Server;
shutdown: () => Promise<void>;
};
try {
await withFakeAgent(
'opencode',
`
process.on('SIGTERM', () => {});
setInterval(() => {}, 1000);
`,
async () => {
const activeResponse = await fetch(`${started.url}/api/runs`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ agentId: 'opencode', message: 'hello' }),
});
expect(activeResponse.status).toBe(202);
const { runId } = await activeResponse.json() as { runId: string };
await waitForRunStatus(started.url, runId, (status) => status === 'running');
const shutdownPromise = started.shutdown();
const runResponse = await fetch(`${started.url}/api/runs`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ agentId: 'opencode', message: 'late run' }),
});
const chatResponse = await fetch(`${started.url}/api/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ agentId: 'opencode', message: 'late chat' }),
});
expect(runResponse.status).toBe(503);
expect(chatResponse.status).toBe(503);
await shutdownPromise;
},
);
} finally {
if (previousGrace == null) {
delete process.env.OD_CHAT_RUN_SHUTDOWN_GRACE_MS;
} else {
process.env.OD_CHAT_RUN_SHUTDOWN_GRACE_MS = previousGrace;
}
await new Promise<void>((resolve) => started.server.close(() => resolve()));
}
});
});
async function readSseUntil(response: Response, marker: string): Promise<string> {
const reader = response.body!.getReader();
const decoder = new TextDecoder();
@ -400,14 +455,18 @@ async function readSseUntil(response: Response, marker: string): Promise<string>
return body;
}
async function waitForRunStatus(baseUrl: string, runId: string): Promise<{ status: string }> {
async function waitForRunStatus(
baseUrl: string,
runId: string,
done: (status: string) => boolean = (status) => status !== 'queued' && status !== 'running',
): Promise<{ status: string }> {
for (let attempt = 0; attempt < 120; attempt += 1) {
const statusResponse = await fetch(`${baseUrl}/api/runs/${runId}`);
const statusBody = await statusResponse.json() as { status: string };
if (statusBody.status !== 'queued' && statusBody.status !== 'running') return statusBody;
if (done(statusBody.status)) return statusBody;
await new Promise((resolve) => setTimeout(resolve, 25));
}
throw new Error('run did not finish');
throw new Error('run did not reach expected status');
}
describe('chat prompt helpers', () => {

View file

@ -23,6 +23,15 @@ import {
let tempDir: string | null = null;
function tableColumnNames(rows: unknown[]): string[] {
return rows.map((row) => {
if (!row || typeof row !== 'object' || !('name' in row) || typeof row.name !== 'string') {
throw new Error('expected PRAGMA table_info row with string name');
}
return row.name;
});
}
afterEach(() => {
closeDatabase();
if (tempDir) fs.rmSync(tempDir, { recursive: true, force: true });
@ -36,13 +45,12 @@ describe('preview comment persistence', () => {
const previewColumns = db
.prepare(`PRAGMA table_info(preview_comments)`)
.all()
.map((column: { name: string }) => column.name);
.all();
const critiqueTable = db
.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name='critique_runs'`)
.get() as { name?: string } | undefined;
expect(previewColumns).toEqual(
expect(tableColumnNames(previewColumns)).toEqual(
expect.arrayContaining(['selection_kind', 'member_count', 'pod_members_json']),
);
expect(critiqueTable?.name).toBe('critique_runs');

View file

@ -5,8 +5,10 @@ import { tmpdir } from 'node:os';
import {
configureComposioConfigStore,
deleteComposioAuthConfigId,
readComposioConfig,
readPublicComposioConfig,
setComposioAuthConfigId,
writeComposioConfig,
} from '../src/connectors/composio-config.js';
import { composioConnectorProvider, getStaticComposioCatalogDefinitions } from '../src/connectors/composio.js';
@ -43,10 +45,24 @@ describe('composio config', () => {
configured: true,
apiKeyTail: '1234',
});
expect(readComposioConfig()).toMatchObject({ apiKey: 'cmp_secret_1234' });
expect(readComposioConfig()).toMatchObject({ apiKey: 'cmp_secret_1234', authConfigIds: {} });
await expect(readFile(path.join(dir, 'connectors', 'composio-config.json'), 'utf8')).resolves.toContain('cmp_secret_1234');
});
it('preserves and updates persisted auth config ids', async () => {
await useTempComposioStore();
writeComposioConfig({ apiKey: 'stored_secret', authConfigIds: { github: 'ac_github' } });
writeComposioConfig({});
setComposioAuthConfigId('slack', 'ac_slack');
deleteComposioAuthConfigId('github');
expect(readComposioConfig()).toEqual({
apiKey: 'stored_secret',
authConfigIds: { slack: 'ac_slack' },
});
});
it('does not read Composio credentials from environment variables', async () => {
await useTempComposioStore();
const originalApiKey = process.env.COMPOSIO_API_KEY;
@ -72,16 +88,27 @@ describe('composio config', () => {
expect(publicConfig.configured).toBe(false);
expect(composioConnectorProvider.isConfigured(composioDefinition())).toBe(false);
expect(readComposioConfig()).toEqual({ apiKey: '', authConfigIds: {} });
});
it('ignores stale persisted technical fields', async () => {
it('clears stored auth config ids when the API key changes', async () => {
await useTempComposioStore();
writeComposioConfig({ apiKey: 'stored_secret', authConfigIds: { github: 'ac_github' } });
const publicConfig = writeComposioConfig({ apiKey: 'new_secret' });
expect(publicConfig).toEqual({ configured: true, apiKeyTail: 'cret' });
expect(readComposioConfig()).toEqual({ apiKey: 'new_secret', authConfigIds: {} });
});
it('ignores stale unsupported persisted technical fields', async () => {
await useTempComposioStore();
writeComposioConfig({ apiKey: 'stored_secret' });
const publicConfig = writeComposioConfig({ apiKey: '', baseUrl: '', userId: '', timeoutMs: null, authConfigIds: { github: 'stale' } });
const publicConfig = writeComposioConfig({ apiKey: '', baseUrl: '', userId: '', timeoutMs: null });
expect(publicConfig).toEqual({ configured: false, apiKeyTail: '' });
expect(readComposioConfig()).toEqual({ apiKey: '' });
expect(readComposioConfig()).toEqual({ apiKey: '', authConfigIds: {} });
});
it('loads persisted Composio catalog cache into fast definitions', async () => {
@ -99,6 +126,7 @@ describe('composio config', () => {
category: 'Communication',
providerConnectorId: 'SLACK',
authentication: 'composio',
toolCount: 48,
tools: [
{
name: 'slack.slack_list_channels',
@ -121,6 +149,7 @@ describe('composio config', () => {
expect(composioConnectorProvider.getFastDefinitions().find((definition) => definition.id === 'slack')).toMatchObject({
id: 'slack',
toolCount: 48,
tools: [expect.objectContaining({
name: 'slack.slack_list_channels',
curation: expect.objectContaining({ useCases: ['personal_daily_digest'] }),

View file

@ -1,39 +1,77 @@
// @ts-nocheck
import { request as httpRequest } from 'node:http';
import { request as httpRequest, type Server } from 'node:http';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { COMPOSIO_LOGO_CACHE_MAX_ENTRIES } from '../src/connectors/routes.js';
import { startServer } from '../src/server.js';
import { ComposioConnectorProvider, composioConnectorProvider, getStaticComposioCatalogDefinitions } from '../src/connectors/composio.js';
import { readComposioConfig, writeComposioConfig } from '../src/connectors/composio-config.js';
import type { ConnectorCatalogDefinition, ConnectorDetail } from '../src/connectors/catalog.js';
import { readComposioConfig, writeComposioConfig, type ComposioConfig } from '../src/connectors/composio-config.js';
import { deleteConnectorCredentialsByProvider } from '../src/connectors/service.js';
import { CHAT_TOOL_ENDPOINTS, CHAT_TOOL_OPERATIONS, toolTokenRegistry } from '../src/tool-tokens.js';
let server;
let baseUrl;
let originalComposioConfig;
const originalFetch = globalThis.fetch;
let lastComposioLinkRequest;
let lastComposioAuthConfigRequest;
let composioDiscoveryRequestCounts;
type JsonObject = Record<string, any>;
type StartedServer = { url: string; server: Server };
type DiscoveryRequestCounts = { authConfigs: number; createdAuthConfigs: number; toolkits: number; tools: number };
type Deferred = { promise: Promise<void>; resolve: () => void };
type ComposioRequestBody = JsonObject;
type FetchInput = Parameters<typeof fetch>[0];
type FetchReturn = Awaited<ReturnType<typeof fetch>>;
type ComposioLogoFetch = (parsed: URL, init: RequestInit | undefined, input: FetchInput) => Promise<FetchReturn> | FetchReturn;
function composioJson(body, status = 200) {
interface MockComposioFetchOptions {
authConfigs?: JsonObject[];
createAuthConfigResponse?: JsonObject;
delayFirstAuthConfigs?: { started: Deferred; release: Deferred };
delayFirstToolkits?: { started: Deferred; release: Deferred };
logoFetch?: ComposioLogoFetch;
linkResponse?: JsonObject | Response | Array<JsonObject | Response> | ((requestBody: ComposioRequestBody) => JsonObject | Response);
toolsFailureToolkits?: string[];
toolkits?: JsonObject[];
}
interface JsonFetchResponse<TBody = JsonObject> {
status: number;
body: TBody;
}
interface HostHeaderResponse {
status: number | undefined;
body: string;
}
let server: Server | undefined;
let baseUrl = '';
let originalComposioConfig: ComposioConfig;
const originalFetch = globalThis.fetch;
let lastComposioLinkRequest: ComposioRequestBody | undefined;
let lastComposioAuthConfigRequest: ComposioRequestBody | undefined;
let composioDiscoveryRequestCounts: DiscoveryRequestCounts;
function composioJson(body: JsonObject, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'content-type': 'application/json' },
});
}
function createDeferred() {
let resolve;
const promise = new Promise((innerResolve) => {
resolve = innerResolve;
function createDeferred(): Deferred {
let resolve!: () => void;
const promise = new Promise<void>((innerResolve) => {
resolve = () => innerResolve(undefined);
});
return { promise, resolve };
}
function mockComposioFetch(options = {}) {
async function closeServer(): Promise<void> {
await new Promise<void>((resolve, reject) => {
if (!server) return resolve(undefined);
server.close((error?: Error) => (error ? reject(error) : resolve(undefined)));
});
server = undefined;
}
function mockComposioFetch(options: MockComposioFetchOptions = {}): void {
const {
authConfigs = [{ id: 'ac_github', status: 'ENABLED', toolkit: { slug: 'github' } }],
createAuthConfigResponse,
@ -41,9 +79,11 @@ function mockComposioFetch(options = {}) {
delayFirstToolkits,
logoFetch,
linkResponse = { connected_account_id: 'ca_github', status: 'ACTIVE', account_label: 'octocat@example.com' },
toolsFailureToolkits = [],
toolkits,
} = options;
composioDiscoveryRequestCounts = { authConfigs: 0, createdAuthConfigs: 0, toolkits: 0, tools: 0 };
vi.stubGlobal('fetch', async (input, init) => {
vi.stubGlobal('fetch', async (input: FetchInput, init?: RequestInit): Promise<FetchReturn> => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url;
if (url.startsWith('http://127.0.0.1:') || url.startsWith('http://localhost:')) {
return originalFetch(input, init);
@ -68,6 +108,7 @@ function mockComposioFetch(options = {}) {
composioDiscoveryRequestCounts.createdAuthConfigs += 1;
lastComposioAuthConfigRequest = typeof init?.body === 'string' ? JSON.parse(init.body) : undefined;
const toolkitSlug = lastComposioAuthConfigRequest?.toolkit?.slug ?? 'GITHUB';
if (createAuthConfigResponse instanceof Response) return createAuthConfigResponse;
return composioJson(createAuthConfigResponse ?? { id: `ac_${String(toolkitSlug).toLowerCase()}`, status: 'ENABLED', toolkit: { slug: toolkitSlug } });
}
if (parsed.pathname === '/api/v3.1/toolkits') {
@ -76,12 +117,30 @@ function mockComposioFetch(options = {}) {
delayFirstToolkits.started.resolve();
await delayFirstToolkits.release.promise;
}
return composioJson({ items: [{ slug: 'github', name: 'GitHub', description: 'GitHub toolkit', categories: [{ name: 'Developer' }] }] });
return composioJson({ items: toolkits ?? [
{ slug: 'github', name: 'GitHub', description: 'GitHub toolkit', categories: [{ name: 'Developer' }], meta: { tools_count: 12 } },
{ slug: 'apaleo', name: 'Apaleo', description: 'Apaleo toolkit', categories: [{ name: 'Hospitality' }], meta: { tools_count: 29 } },
] });
}
if (parsed.pathname === '/api/v3.1/tools' && toolsFailureToolkits.includes(parsed.searchParams.get('toolkit_slug') ?? '')) {
composioDiscoveryRequestCounts.tools += 1;
return composioJson({ message: 'Composio tools unavailable' }, 503);
}
if (parsed.pathname === '/api/v3.1/tools' && parsed.searchParams.get('toolkit_slug') === 'github') {
composioDiscoveryRequestCounts.tools += 1;
return composioJson({ items: [{ slug: 'GITHUB_SEARCH_REPOSITORIES', name: 'Search repositories', description: 'Search public and private repositories', toolkit: { slug: 'github' }, input_parameters: { type: 'object', properties: { query: { type: 'string' } }, required: ['query'], additionalProperties: false }, tags: ['read'] }] });
}
if (parsed.pathname === '/api/v3.1/tools' && parsed.searchParams.get('toolkit_slug') === 'notion') {
composioDiscoveryRequestCounts.tools += 1;
return composioJson({
items: [
{ slug: 'NOTION_SEARCH', name: 'Search Notion', description: 'Search Notion pages and databases.', toolkit: { slug: 'notion' }, input_parameters: { type: 'object', properties: { query: { type: 'string' } }, required: ['query'], additionalProperties: false }, tags: ['read'] },
{ slug: 'NOTION_FETCH_DATABASE', name: 'Fetch database', description: 'Read a Notion database.', toolkit: { slug: 'notion' }, input_parameters: { type: 'object', properties: { database_id: { type: 'string' } }, required: ['database_id'], additionalProperties: false }, tags: ['read'] },
{ slug: 'NOTION_GET_PAGE', name: 'Get page', description: 'Read a Notion page.', toolkit: { slug: 'notion' }, input_parameters: { type: 'object', properties: { page_id: { type: 'string' } }, required: ['page_id'], additionalProperties: false }, tags: ['read'] },
],
total_items: 48,
});
}
if (parsed.pathname === '/api/v3.1/tools' && parsed.searchParams.get('toolkit_slug') === 'slack') {
composioDiscoveryRequestCounts.tools += 1;
return composioJson({ items: [
@ -89,15 +148,35 @@ function mockComposioFetch(options = {}) {
{ slug: 'SLACK_SEND_MESSAGE', name: 'Send message', description: 'Send a Slack message', toolkit: { slug: 'slack' }, input_parameters: { type: 'object', additionalProperties: true }, tags: ['write'] },
] });
}
if (parsed.pathname === '/api/v3.1/tools' && parsed.searchParams.get('toolkit_slug') === 'apaleo') {
composioDiscoveryRequestCounts.tools += 1;
const cursor = parsed.searchParams.get('cursor');
return composioJson({
items: cursor
? [{ slug: 'APALEO_GET_PROPERTY', name: 'Get property', description: 'Read an Apaleo property.', toolkit: { slug: 'apaleo' }, input_parameters: { type: 'object', additionalProperties: false }, tags: ['read'] }]
: [{ slug: 'APALEO_LIST_RESERVATIONS', name: 'List reservations', description: 'List Apaleo reservations.', toolkit: { slug: 'apaleo' }, input_parameters: { type: 'object', additionalProperties: false }, tags: ['read'] }],
total_items: 29,
...(cursor ? {} : { next_cursor: 'cursor_page_2' }),
});
}
if (parsed.pathname === '/api/v3.1/connected_accounts/link') {
lastComposioLinkRequest = typeof init?.body === 'string' ? JSON.parse(init.body) : undefined;
return composioJson(linkResponse);
const nextLinkResponse = typeof linkResponse === 'function'
? linkResponse(lastComposioLinkRequest ?? {})
: Array.isArray(linkResponse)
? linkResponse.shift()
: linkResponse;
if (nextLinkResponse instanceof Response) return nextLinkResponse;
return composioJson(nextLinkResponse ?? {});
}
if (parsed.pathname === '/api/v3/connected_accounts/ca_github') {
return composioJson({ connected_account_id: 'ca_github', status: 'ACTIVE', account_label: 'octocat@example.com', toolkit: { slug: 'github' }, auth_config: { id: 'ac_github' } });
return composioJson({ connected_account_id: 'ca_github', status: 'ACTIVE', account_label: 'octocat@example.com', toolkit: { slug: 'github' }, auth_config: { id: lastComposioLinkRequest?.auth_config_id ?? 'ac_github' } });
}
if (parsed.pathname === '/api/v3/connected_accounts/ca_slack') {
return composioJson({ connected_account_id: 'ca_slack', status: 'ACTIVE', account_label: 'slack@example.com', toolkit: { slug: 'slack' }, auth_config: { id: 'ac_slack' } });
return composioJson({ connected_account_id: 'ca_slack', status: 'ACTIVE', account_label: 'slack@example.com', toolkit: { slug: 'slack' }, auth_config: { id: lastComposioLinkRequest?.auth_config_id ?? 'ac_slack' } });
}
if (parsed.pathname === '/api/v3/connected_accounts/ca_notion') {
return composioJson({ connected_account_id: 'ca_notion', status: 'ACTIVE', account_label: 'notion@example.com', toolkit: { slug: 'notion' }, auth_config: { id: lastComposioLinkRequest?.auth_config_id ?? 'ac_notion' } });
}
if (parsed.pathname === '/api/v3.1/tools/execute/GITHUB_SEARCH_REPOSITORIES') {
return composioJson({ successful: true, data: { results: [] }, log_id: 'log_1' });
@ -114,7 +193,7 @@ beforeEach(async () => {
lastComposioLinkRequest = undefined;
lastComposioAuthConfigRequest = undefined;
mockComposioFetch();
const started = await startServer({ port: 0, returnServer: true });
const started = await startServer({ port: 0, returnServer: true }) as StartedServer;
server = started.server;
baseUrl = started.url;
await jsonFetch(`${baseUrl}/api/connectors/composio/config`, {
@ -128,9 +207,9 @@ afterEach(async () => {
deleteConnectorCredentialsByProvider('composio');
writeComposioConfig(originalComposioConfig ?? { apiKey: '' });
composioConnectorProvider.clearDiscoveryCache();
await new Promise((resolve, reject) => {
await new Promise<void>((resolve, reject) => {
if (!server) return resolve(undefined);
server.close((error) => (error ? reject(error) : resolve(undefined)));
server.close((error?: Error) => (error ? reject(error) : resolve(undefined)));
});
server = undefined;
toolTokenRegistry.clear();
@ -138,14 +217,14 @@ afterEach(async () => {
vi.useRealTimers();
});
async function jsonFetch(url, init) {
async function jsonFetch<TBody = JsonObject>(url: string, init?: RequestInit): Promise<JsonFetchResponse<TBody>> {
const response = await fetch(url, init);
return { status: response.status, body: await response.json() };
return { status: response.status, body: await response.json() as TBody };
}
async function requestWithHostHeader(method, url, host, body) {
async function requestWithHostHeader(method: string, url: string, host: string, body?: JsonObject): Promise<HostHeaderResponse> {
const target = new URL(url);
return await new Promise((resolve, reject) => {
return await new Promise<HostHeaderResponse>((resolve, reject) => {
const req = httpRequest(
{
protocol: target.protocol,
@ -159,8 +238,8 @@ async function requestWithHostHeader(method, url, host, body) {
},
},
(res) => {
const chunks = [];
res.on('data', (chunk) => chunks.push(chunk));
const chunks: Buffer[] = [];
res.on('data', (chunk: Buffer) => chunks.push(chunk));
res.on('end', () => {
resolve({
status: res.statusCode,
@ -174,15 +253,15 @@ async function requestWithHostHeader(method, url, host, body) {
});
}
async function postWithHostHeader(url, host) {
async function postWithHostHeader(url: string, host: string): Promise<HostHeaderResponse> {
return requestWithHostHeader('POST', url, host);
}
async function putWithHostHeader(url, host, body) {
async function putWithHostHeader(url: string, host: string, body: JsonObject): Promise<HostHeaderResponse> {
return requestWithHostHeader('PUT', url, host, body);
}
function mintConnectorToolToken(projectId = 'connector-route-project', runId = 'connector-route-run', overrides = {}) {
function mintConnectorToolToken(projectId = 'connector-route-project', runId = 'connector-route-run', overrides: Partial<Parameters<typeof toolTokenRegistry.mint>[0]> = {}): string {
return toolTokenRegistry.mint({
projectId,
runId,
@ -197,40 +276,116 @@ describe('connector routes', () => {
const response = await jsonFetch(`${baseUrl}/api/connectors`);
expect(response.status).toBe(200);
expect(response.body.connectors.map((connector) => connector.id)).toEqual(expect.arrayContaining(['github', 'notion', 'google_drive', 'slack', 'zoom']));
expect(response.body.connectors.map((connector: ConnectorDetail) => connector.id)).toEqual(expect.arrayContaining(['github', 'notion', 'google_drive', 'slack', 'zoom']));
expect(response.body.connectors.length).toBeGreaterThan(100);
const github = response.body.connectors.find((connector) => connector.id === 'github');
const github = response.body.connectors.find((connector: ConnectorDetail) => connector.id === 'github');
expect(github).toMatchObject({
id: 'github',
name: 'GitHub',
provider: 'composio',
toolCount: 2,
auth: { provider: 'composio', configured: false },
});
expect(github.tools).toEqual(expect.arrayContaining([expect.objectContaining({ name: 'github.github_search_repositories' })]));
expect(response.body.connectors.find((connector) => connector.id === 'google_drive')).toMatchObject({
expect(response.body.connectors.find((connector: ConnectorDetail) => connector.id === 'google_drive')).toMatchObject({
id: 'google_drive',
auth: { provider: 'composio', configured: false },
});
expect(response.body.connectors.find((connector) => connector.id === 'notion')).toMatchObject({
expect(response.body.connectors.find((connector: ConnectorDetail) => connector.id === 'notion')).toMatchObject({
id: 'notion',
toolCount: 48,
auth: { provider: 'composio', configured: false },
});
expect(response.body.connectors.find((connector: ConnectorDetail) => connector.id === 'airtable')).toMatchObject({
id: 'airtable',
tools: [],
toolCount: 25,
});
expect(composioDiscoveryRequestCounts).toEqual({ authConfigs: 0, createdAuthConfigs: 0, toolkits: 0, tools: 0 });
});
it('reuses Composio discovery results across consecutive discovery requests', async () => {
it('reuses fast Composio metadata discovery results without hydrating tools', async () => {
const first = await jsonFetch(`${baseUrl}/api/connectors/discovery`);
const second = await jsonFetch(`${baseUrl}/api/connectors/discovery`);
expect(first.status).toBe(200);
expect(second.status).toBe(200);
expect(first.body.connectors.map((connector) => connector.id)).toEqual(expect.arrayContaining(['github', 'notion', 'google_drive', 'slack', 'zoom']));
expect(second.body.connectors.map((connector) => connector.id)).toEqual(expect.arrayContaining(['github', 'notion', 'google_drive', 'slack', 'zoom']));
expect(first.body.connectors.find((connector) => connector.id === 'slack')?.tools).toEqual(expect.arrayContaining([expect.objectContaining({ name: 'slack.slack_list_channels' })]));
expect(first.body.connectors.map((connector: ConnectorDetail) => connector.id)).toEqual(expect.arrayContaining(['github', 'notion', 'google_drive', 'slack', 'zoom']));
expect(second.body.connectors.map((connector: ConnectorDetail) => connector.id)).toEqual(expect.arrayContaining(['github', 'notion', 'google_drive', 'slack', 'zoom']));
expect(first.body.connectors.find((connector: ConnectorDetail) => connector.id === 'github')).toMatchObject({ toolCount: 12 });
expect(first.body.connectors.find((connector: ConnectorDetail) => connector.id === 'apaleo')).toMatchObject({ toolCount: 29 });
expect(first.body.connectors.find((connector: ConnectorDetail) => connector.id === 'slack')?.tools).toEqual([]);
expect(first.body.meta).toMatchObject({ provider: 'composio' });
expect(composioDiscoveryRequestCounts).toEqual({ authConfigs: 1, createdAuthConfigs: 0, toolkits: 1, tools: 0 });
});
it('preserves static advertised tool counts when live toolkit metadata omits counts', async () => {
mockComposioFetch({
toolkits: [
{ slug: 'notion', name: 'Notion', description: 'Notion toolkit', categories: [{ name: 'Productivity' }], meta: {} },
],
});
composioConnectorProvider.clearDiscoveryCache();
const response = await jsonFetch(`${baseUrl}/api/connectors/discovery?refresh=true`);
expect(response.status).toBe(200);
expect(response.body.connectors.find((connector: ConnectorDetail) => connector.id === 'notion')).toMatchObject({
id: 'notion',
toolCount: 48,
});
expect(composioDiscoveryRequestCounts).toEqual({ authConfigs: 1, createdAuthConfigs: 0, toolkits: 1, tools: 0 });
});
it('hydrates Composio tools only when explicitly requested', async () => {
const response = await jsonFetch(`${baseUrl}/api/connectors/discovery?hydrateTools=true`);
expect(response.status).toBe(200);
expect(response.body.connectors.find((connector: ConnectorDetail) => connector.id === 'slack')?.tools).toEqual(expect.arrayContaining([expect.objectContaining({ name: 'slack.slack_list_channels' })]));
expect(response.body.connectors.find((connector: ConnectorDetail) => connector.id === 'github')).toMatchObject({ toolCount: 12 });
expect(composioDiscoveryRequestCounts).toEqual({ authConfigs: 1, createdAuthConfigs: 0, toolkits: 1, tools: 4 });
});
it('hydrates one connector tool preview page without hydrating unrelated connectors', async () => {
const metadata = await jsonFetch(`${baseUrl}/api/connectors/apaleo`);
const preview = await jsonFetch(`${baseUrl}/api/connectors/apaleo?hydrateTools=true&toolsLimit=1`);
const nextPage = await jsonFetch(`${baseUrl}/api/connectors/apaleo?hydrateTools=true&toolsLimit=1&toolsCursor=cursor_page_2`);
expect(metadata.status).toBe(200);
expect(metadata.body.connector).toMatchObject({ id: 'apaleo', tools: [], toolCount: 29 });
expect(preview.status).toBe(200);
expect(preview.body.connector).toMatchObject({
id: 'apaleo',
toolCount: 29,
toolsNextCursor: 'cursor_page_2',
toolsHasMore: true,
tools: [expect.objectContaining({ name: 'apaleo.apaleo_list_reservations' })],
});
expect(nextPage.status).toBe(200);
expect(nextPage.body.connector).toMatchObject({
id: 'apaleo',
toolCount: 29,
toolsHasMore: false,
tools: [expect.objectContaining({ name: 'apaleo.apaleo_get_property' })],
});
expect(nextPage.body.connector.toolsNextCursor).toBeUndefined();
expect(composioDiscoveryRequestCounts).toEqual({ authConfigs: 1, createdAuthConfigs: 0, toolkits: 1, tools: 2 });
});
it('propagates Composio tool page failures during preview hydration', async () => {
mockComposioFetch({ toolsFailureToolkits: ['apaleo'] });
composioConnectorProvider.clearDiscoveryCache();
const preview = await jsonFetch(`${baseUrl}/api/connectors/apaleo?hydrateTools=true&toolsLimit=1`);
expect(preview.status).toBe(502);
expect(preview.body.error).toMatchObject({
code: 'CONNECTOR_EXECUTION_FAILED',
message: 'Composio tools unavailable',
});
expect(composioDiscoveryRequestCounts).toEqual({ authConfigs: 1, createdAuthConfigs: 0, toolkits: 1, tools: 1 });
});
it('returns connector statuses by connectorId', async () => {
await jsonFetch(`${baseUrl}/api/connectors/github/connect`, { method: 'POST' });
@ -244,14 +399,14 @@ describe('connector routes', () => {
it('returns static catalog connectors even when Composio auth configs are empty', async () => {
await new Promise((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve(undefined)));
server!.close((error?: Error) => (error ? reject(error) : resolve(undefined)));
});
mockComposioFetch({
authConfigs: [],
linkResponse: { connected_account_id: 'ca_slack', status: 'ACTIVE', account_label: 'slack@example.com' },
});
composioConnectorProvider.clearDiscoveryCache();
const started = await startServer({ port: 0, returnServer: true });
const started = await startServer({ port: 0, returnServer: true }) as StartedServer;
server = started.server;
baseUrl = started.url;
await jsonFetch(`${baseUrl}/api/connectors/composio/config`, {
@ -263,8 +418,8 @@ describe('connector routes', () => {
const response = await jsonFetch(`${baseUrl}/api/connectors`);
expect(response.status).toBe(200);
expect(response.body.connectors.map((connector) => connector.id)).toEqual(expect.arrayContaining(['github', 'notion', 'google_drive', 'slack', 'zoom']));
expect(response.body.connectors.every((connector) => connector.auth?.configured === false)).toBe(true);
expect(response.body.connectors.map((connector: ConnectorDetail) => connector.id)).toEqual(expect.arrayContaining(['github', 'notion', 'google_drive', 'slack', 'zoom']));
expect(response.body.connectors.every((connector: ConnectorDetail) => connector.auth?.configured === false)).toBe(true);
});
it('returns static catalog connectors before Composio is configured', async () => {
@ -274,8 +429,8 @@ describe('connector routes', () => {
const response = await jsonFetch(`${baseUrl}/api/connectors`);
expect(response.status).toBe(200);
expect(response.body.connectors.map((connector) => connector.id)).toEqual(expect.arrayContaining(['github', 'notion', 'google_drive', 'slack', 'zoom']));
expect(response.body.connectors.every((connector) => connector.auth?.configured === false)).toBe(true);
expect(response.body.connectors.map((connector: ConnectorDetail) => connector.id)).toEqual(expect.arrayContaining(['github', 'notion', 'google_drive', 'slack', 'zoom']));
expect(response.body.connectors.every((connector: ConnectorDetail) => connector.auth?.configured === false)).toBe(true);
});
it('returns connector detail and 404 for unknown connectors', async () => {
@ -291,6 +446,7 @@ describe('connector routes', () => {
it('connects and disconnects a Composio connector', async () => {
const connect = await jsonFetch(`${baseUrl}/api/connectors/github/connect`, { method: 'POST' });
const afterConnect = { ...composioDiscoveryRequestCounts };
expect(connect.status).toBe(200);
expect(connect.body.connector).toMatchObject({ id: 'github', status: 'connected', accountLabel: 'octocat@example.com' });
@ -298,6 +454,7 @@ describe('connector routes', () => {
const disconnect = await jsonFetch(`${baseUrl}/api/connectors/github/connection`, { method: 'DELETE' });
expect(disconnect.status).toBe(200);
expect(disconnect.body.connector).toMatchObject({ id: 'github', status: 'available' });
expect(composioDiscoveryRequestCounts).toEqual(afterConnect);
});
it('rejects cross-origin connector connect requests before starting provider auth', async () => {
@ -339,14 +496,14 @@ describe('connector routes', () => {
it('creates a managed Composio auth config when connecting an unconfigured connector', async () => {
await new Promise((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve(undefined)));
server!.close((error?: Error) => (error ? reject(error) : resolve(undefined)));
});
mockComposioFetch({
authConfigs: [],
linkResponse: { connected_account_id: 'ca_slack', status: 'ACTIVE', account_label: 'slack@example.com' },
});
composioConnectorProvider.clearDiscoveryCache();
const started = await startServer({ port: 0, returnServer: true });
const started = await startServer({ port: 0, returnServer: true }) as StartedServer;
server = started.server;
baseUrl = started.url;
await jsonFetch(`${baseUrl}/api/connectors/composio/config`, {
@ -356,6 +513,9 @@ describe('connector routes', () => {
});
const connect = await jsonFetch(`${baseUrl}/api/connectors/slack/connect`, { method: 'POST' });
expect(composioDiscoveryRequestCounts).toEqual({ authConfigs: 1, createdAuthConfigs: 1, toolkits: 0, tools: 0 });
expect(readComposioConfig().authConfigIds.slack).toBe('ac_slack');
const token = mintConnectorToolToken('connector-auto-auth-project', 'connector-auto-auth-run');
const tools = await jsonFetch(`${baseUrl}/api/tools/connectors/list`, {
headers: { Authorization: `Bearer ${token}` },
@ -363,32 +523,150 @@ describe('connector routes', () => {
expect(connect.status).toBe(200);
expect(connect.body.connector).toMatchObject({ id: 'slack', status: 'connected', auth: { configured: true } });
expect(connect.body.connector.tools).toEqual([
expect.objectContaining({ name: 'slack.slack_list_channels' }),
expect.objectContaining({ name: 'slack.slack_send_message' }),
]);
expect(connect.body.connector.tools).toEqual([]);
expect(lastComposioAuthConfigRequest).toEqual({
toolkit: { slug: 'SLACK' },
auth_config: { type: 'use_composio_managed_auth' },
});
expect(lastComposioLinkRequest).toMatchObject({ auth_config_id: 'ac_slack' });
expect(tools.status).toBe(200);
expect(tools.body.connectors.find((connector) => connector.id === 'slack')?.tools).toEqual([
expect(tools.body.connectors.find((connector: ConnectorDetail) => connector.id === 'slack')?.tools).toEqual([
expect.objectContaining({ name: 'slack.slack_list_channels' }),
]);
expect(composioDiscoveryRequestCounts).toMatchObject({ authConfigs: 2, createdAuthConfigs: 1 });
});
it('reuses persisted Composio auth config ids on later connect attempts', async () => {
await closeServer();
mockComposioFetch({
authConfigs: [],
linkResponse: { connected_account_id: 'ca_slack', status: 'ACTIVE', account_label: 'slack@example.com' },
});
composioConnectorProvider.clearDiscoveryCache();
const started = await startServer({ port: 0, returnServer: true }) as StartedServer;
server = started.server;
baseUrl = started.url;
await jsonFetch(`${baseUrl}/api/connectors/composio/config`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ apiKey: 'cmp_test' }),
});
const first = await jsonFetch(`${baseUrl}/api/connectors/slack/connect`, { method: 'POST' });
const afterFirst = { ...composioDiscoveryRequestCounts };
const second = await jsonFetch(`${baseUrl}/api/connectors/slack/connect`, { method: 'POST' });
expect(first.status).toBe(200);
expect(second.status).toBe(200);
expect(readComposioConfig().authConfigIds.slack).toBe('ac_slack');
expect(afterFirst).toEqual({ authConfigs: 1, createdAuthConfigs: 1, toolkits: 0, tools: 0 });
expect(composioDiscoveryRequestCounts).toEqual(afterFirst);
expect(lastComposioLinkRequest).toMatchObject({ auth_config_id: 'ac_slack' });
});
it('prepares a Composio auth config before connect and then reuses it for the link', async () => {
await closeServer();
mockComposioFetch({
authConfigs: [],
linkResponse: { connected_account_id: 'ca_slack', status: 'ACTIVE', account_label: 'slack@example.com' },
});
composioConnectorProvider.clearDiscoveryCache();
const started = await startServer({ port: 0, returnServer: true }) as StartedServer;
server = started.server;
baseUrl = started.url;
await jsonFetch(`${baseUrl}/api/connectors/composio/config`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ apiKey: 'cmp_test' }),
});
const prepare = await jsonFetch(`${baseUrl}/api/connectors/auth-configs/prepare`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ connectorIds: ['slack'] }),
});
const afterPrepare = { ...composioDiscoveryRequestCounts };
const connect = await jsonFetch(`${baseUrl}/api/connectors/slack/connect`, { method: 'POST' });
expect(prepare.status).toBe(200);
expect(prepare.body.results.slack).toEqual({ status: 'ready', authConfigId: 'ac_slack' });
expect(readComposioConfig().authConfigIds.slack).toBe('ac_slack');
expect(afterPrepare).toEqual({ authConfigs: 1, createdAuthConfigs: 1, toolkits: 0, tools: 0 });
expect(connect.status).toBe(200);
expect(composioDiscoveryRequestCounts).toEqual(afterPrepare);
expect(lastComposioLinkRequest).toMatchObject({ auth_config_id: 'ac_slack' });
});
it('refreshes a stale persisted auth config id once when creating a link fails', async () => {
await closeServer();
mockComposioFetch({
authConfigs: [{ id: 'ac_slack_fresh', status: 'ENABLED', toolkit: { slug: 'slack' } }],
linkResponse: (requestBody: ComposioRequestBody) => requestBody.auth_config_id === 'ac_slack_stale'
? composioJson({ message: 'stale auth config' }, 404)
: { connected_account_id: 'ca_slack', status: 'ACTIVE', account_label: 'slack@example.com' },
});
composioConnectorProvider.clearDiscoveryCache();
const started = await startServer({ port: 0, returnServer: true }) as StartedServer;
server = started.server;
baseUrl = started.url;
writeComposioConfig({ apiKey: 'cmp_test', authConfigIds: { slack: 'ac_slack_stale' } });
const connect = await jsonFetch(`${baseUrl}/api/connectors/slack/connect`, { method: 'POST' });
expect(connect.status).toBe(200);
expect(readComposioConfig().authConfigIds.slack).toBe('ac_slack_fresh');
expect(composioDiscoveryRequestCounts).toEqual({ authConfigs: 1, createdAuthConfigs: 0, toolkits: 0, tools: 0 });
expect(lastComposioLinkRequest).toMatchObject({ auth_config_id: 'ac_slack_fresh' });
});
it('marks Composio auth as configured from a persisted local auth config id', async () => {
writeComposioConfig({ apiKey: 'cmp_test', authConfigIds: { slack: 'ac_slack' } });
const response = await jsonFetch(`${baseUrl}/api/connectors`);
expect(response.status).toBe(200);
expect(response.body.connectors.find((connector: ConnectorDetail) => connector.id === 'slack')).toMatchObject({
auth: { provider: 'composio', configured: true },
});
});
it('surfaces nested Composio auth config creation errors', async () => {
await closeServer();
mockComposioFetch({
authConfigs: [],
createAuthConfigResponse: composioJson({
error: {
message: 'Default auth config not found for toolkit "canvas".',
suggested_fix: 'Use type "use_custom_auth" with your own credentials.',
},
}, 400),
});
composioConnectorProvider.clearDiscoveryCache();
const started = await startServer({ port: 0, returnServer: true }) as StartedServer;
server = started.server;
baseUrl = started.url;
await jsonFetch(`${baseUrl}/api/connectors/composio/config`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ apiKey: 'cmp_test' }),
});
const connect = await jsonFetch(`${baseUrl}/api/connectors/canvas/connect`, { method: 'POST' });
expect(connect.status).toBe(502);
expect(connect.body.error.message).toBe('Default auth config not found for toolkit "canvas".');
});
it('rejects immediate Composio connections when account validation does not match the connector', async () => {
await new Promise((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve(undefined)));
server!.close((error?: Error) => (error ? reject(error) : resolve(undefined)));
});
mockComposioFetch({
authConfigs: [],
linkResponse: { connected_account_id: 'ca_github', status: 'ACTIVE', account_label: 'octocat@example.com' },
});
composioConnectorProvider.clearDiscoveryCache();
const started = await startServer({ port: 0, returnServer: true });
const started = await startServer({ port: 0, returnServer: true }) as StartedServer;
server = started.server;
baseUrl = started.url;
await jsonFetch(`${baseUrl}/api/connectors/composio/config`, {
@ -407,7 +685,7 @@ describe('connector routes', () => {
const started = createDeferred();
const release = createDeferred();
await new Promise((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve(undefined)));
server!.close((error?: Error) => (error ? reject(error) : resolve(undefined)));
});
mockComposioFetch({
authConfigs: [],
@ -415,7 +693,7 @@ describe('connector routes', () => {
linkResponse: { connected_account_id: 'ca_slack', status: 'ACTIVE', account_label: 'slack@example.com' },
});
composioConnectorProvider.clearDiscoveryCache();
const restarted = await startServer({ port: 0, returnServer: true });
const restarted = await startServer({ port: 0, returnServer: true }) as StartedServer;
server = restarted.server;
baseUrl = restarted.url;
await jsonFetch(`${baseUrl}/api/connectors/composio/config`, {
@ -423,15 +701,16 @@ describe('connector routes', () => {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ apiKey: 'cmp_test' }),
});
const staleDiscovery = composioConnectorProvider.listDefinitions();
const staleDiscovery = composioConnectorProvider.listDefinitions(undefined, { hydrateTools: true });
await started.promise;
const slack = getStaticComposioCatalogDefinitions().find((connector) => connector.id === 'slack');
await composioConnectorProvider.connect(slack, `${baseUrl}/api/connectors/oauth/callback/slack`);
const slack = getStaticComposioCatalogDefinitions().find((connector: ConnectorCatalogDefinition) => connector.id === 'slack');
expect(slack).toBeDefined();
await composioConnectorProvider.connect(slack!, `${baseUrl}/api/connectors/oauth/callback/slack`);
release.resolve();
await staleDiscovery;
const hydrated = await composioConnectorProvider.getDefinition('slack');
const hydrated = await composioConnectorProvider.getHydratedDefinition('slack');
expect(hydrated?.tools.map((tool) => tool.name)).toEqual(expect.arrayContaining(['slack.slack_list_channels', 'slack.slack_send_message']));
expect(hydrated?.allowedToolNames).toEqual(['slack.slack_list_channels']);
});
@ -447,21 +726,22 @@ describe('connector routes', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-04-30T00:00:00.000Z'));
const provider = new ComposioConnectorProvider();
const github = getStaticComposioCatalogDefinitions().find((connector) => connector.id === 'github');
const github = getStaticComposioCatalogDefinitions().find((connector: ConnectorCatalogDefinition) => connector.id === 'github');
expect(github).toBeDefined();
await provider.connect(github, `${baseUrl}/api/connectors/oauth/callback/github`);
expect(provider.pendingConnections.size).toBe(1);
await provider.connect(github!, `${baseUrl}/api/connectors/oauth/callback/github`);
expect((provider as unknown as { pendingConnections: Map<string, unknown> }).pendingConnections.size).toBe(1);
vi.advanceTimersByTime(10 * 60 * 1000 + 1);
await provider.connect(github, `${baseUrl}/api/connectors/oauth/callback/github`);
await provider.connect(github!, `${baseUrl}/api/connectors/oauth/callback/github`);
expect(provider.pendingConnections.size).toBe(1);
expect((provider as unknown as { pendingConnections: Map<string, unknown> }).pendingConnections.size).toBe(1);
});
it('returns branded callback HTML that notifies the opener', async () => {
await new Promise((resolve, reject) => {
if (!server) return resolve(undefined);
server.close((error) => (error ? reject(error) : resolve(undefined)));
server!.close((error?: Error) => (error ? reject(error) : resolve(undefined)));
});
mockComposioFetch({
linkResponse: {
@ -470,7 +750,7 @@ describe('connector routes', () => {
redirect_url: 'https://example.com/oauth',
},
});
const started = await startServer({ port: 0, returnServer: true });
const started = await startServer({ port: 0, returnServer: true }) as StartedServer;
server = started.server;
baseUrl = started.url;
await jsonFetch(`${baseUrl}/api/connectors/composio/config`, {
@ -482,10 +762,13 @@ describe('connector routes', () => {
const connect = await jsonFetch(`${baseUrl}/api/connectors/github/connect`, { method: 'POST' });
expect(connect.status).toBe(200);
expect(connect.body.auth).toMatchObject({ kind: 'redirect_required' });
const callbackUrl = new URL(lastComposioLinkRequest.callback_url);
expect(lastComposioLinkRequest).toBeDefined();
const callbackUrl = new URL(String(lastComposioLinkRequest!.callback_url));
const state = callbackUrl.searchParams.get('state');
expect(state).not.toBeNull();
const response = await fetch(
`${baseUrl}/api/connectors/oauth/callback/github?state=${encodeURIComponent(callbackUrl.searchParams.get('state'))}&status=success&connected_account_id=ca_github`,
`${baseUrl}/api/connectors/oauth/callback/github?state=${encodeURIComponent(state!)}&status=success&connected_account_id=ca_github`,
);
const html = await response.text();
@ -497,6 +780,53 @@ describe('connector routes', () => {
expect(html).toContain('function requestClose()');
expect(html).toContain('Your browser blocked automatic closing. You can close this tab and return to Open Design.');
expect(html).not.toContain('<p>Connector connected. You can close this window.</p>');
expect(readComposioConfig().authConfigIds.github).toBe('ac_github');
const duplicateResponse = await fetch(
`${baseUrl}/api/connectors/oauth/callback/github?state=${encodeURIComponent(callbackUrl.searchParams.get('state') ?? '')}&status=success&connected_account_id=ca_github`,
);
const duplicateHtml = await duplicateResponse.text();
expect(duplicateResponse.status).toBe(200);
expect(duplicateHtml).toContain('GitHub connected');
});
it('cancels pending Composio OAuth state before a stale callback can connect', async () => {
await closeServer();
mockComposioFetch({
linkResponse: {
connected_account_id: 'ca_github',
status: 'INITIATED',
redirect_url: 'https://example.com/oauth',
},
});
const started = await startServer({ port: 0, returnServer: true }) as StartedServer;
server = started.server;
baseUrl = started.url;
await jsonFetch(`${baseUrl}/api/connectors/composio/config`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ apiKey: 'cmp_test' }),
});
const connect = await jsonFetch(`${baseUrl}/api/connectors/github/connect`, { method: 'POST' });
expect(connect.status).toBe(200);
expect(connect.body.auth).toMatchObject({ kind: 'redirect_required' });
const callbackUrl = new URL(lastComposioLinkRequest!.callback_url);
const cancel = await jsonFetch(`${baseUrl}/api/connectors/github/authorization/cancel`, { method: 'POST' });
expect(cancel.status).toBe(200);
expect(cancel.body.connector).toMatchObject({ id: 'github', status: 'available' });
const staleCallback = await fetch(
`${baseUrl}/api/connectors/oauth/callback/github?state=${encodeURIComponent(callbackUrl.searchParams.get('state') ?? '')}&status=success&connected_account_id=ca_github`,
);
const stalePayload = await staleCallback.json() as JsonObject;
expect(staleCallback.status).toBe(400);
expect(stalePayload.error.message).toBe('Composio OAuth state is missing or expired');
const statuses = await jsonFetch(`${baseUrl}/api/connectors/status`);
expect(statuses.body.statuses.github?.status).not.toBe('connected');
});
it('accepts bracketed IPv6 loopback host headers for connector callback URLs', async () => {
@ -506,7 +836,7 @@ describe('connector routes', () => {
expect(response.status).toBe(200);
expect(JSON.parse(response.body).auth).toMatchObject({ kind: 'connected' });
expect(lastComposioLinkRequest.callback_url).toContain(`[::1]:${url.port}/api/connectors/oauth/callback`);
expect(lastComposioLinkRequest?.callback_url).toContain(`[::1]:${url.port}/api/connectors/oauth/callback`);
});
it('accepts IPv4 loopback alias host headers for connector callback URLs', async () => {
@ -516,7 +846,7 @@ describe('connector routes', () => {
expect(response.status).toBe(200);
expect(JSON.parse(response.body).auth).toMatchObject({ kind: 'connected' });
expect(lastComposioLinkRequest.callback_url).toContain(`127.0.0.2:${url.port}/api/connectors/oauth/callback`);
expect(lastComposioLinkRequest?.callback_url).toContain(`127.0.0.2:${url.port}/api/connectors/oauth/callback`);
});
it('times out stalled Composio logo fetches and clears the inflight entry', async () => {
@ -583,7 +913,7 @@ describe('connector routes', () => {
}
return Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
},
};
} as unknown as Response;
}
return new Response(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]), {
status: 200,
@ -623,7 +953,7 @@ describe('connector routes', () => {
arrayBufferCalled = true;
return Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
},
};
} as unknown as Response;
}
return new Response(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]), {
status: 200,
@ -770,23 +1100,59 @@ describe('connector routes', () => {
});
expect(response.status).toBe(200);
expect(response.body.connectors.map((connector) => connector.id)).toEqual(['github']);
expect(response.body.connectors.map((connector: ConnectorDetail) => connector.id)).toEqual(['github']);
expect(response.body.connectors[0].tools).toEqual(expect.arrayContaining([
expect.objectContaining({ name: 'github.github_search_repositories', safety: expect.objectContaining({ sideEffect: 'read', approval: 'auto' }) }),
expect.objectContaining({ name: 'github.github_search_repositories', safety: expect.objectContaining({ sideEffect: 'read', approval: 'auto', reason: expect.any(String) }) }),
]));
expect(composioDiscoveryRequestCounts).toEqual({ authConfigs: 0, createdAuthConfigs: 0, toolkits: 0, tools: 0 });
expect(composioDiscoveryRequestCounts).toEqual({ authConfigs: 1, createdAuthConfigs: 0, toolkits: 1, tools: 1 });
});
it('hydrates connected Composio tools when the fast definition only has partial static previews', async () => {
await closeServer();
mockComposioFetch({
authConfigs: [{ id: 'ac_notion', status: 'ENABLED', toolkit: { slug: 'notion' } }],
linkResponse: { connected_account_id: 'ca_notion', status: 'ACTIVE', account_label: 'notion@example.com' },
toolkits: [
{ slug: 'notion', name: 'Notion', description: 'Notion toolkit', categories: [{ name: 'Productivity' }], meta: { tools_count: 48 } },
],
});
composioConnectorProvider.clearDiscoveryCache();
const started = await startServer({ port: 0, returnServer: true }) as StartedServer;
server = started.server;
baseUrl = started.url;
await jsonFetch(`${baseUrl}/api/connectors/composio/config`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ apiKey: 'cmp_test' }),
});
await jsonFetch(`${baseUrl}/api/connectors/notion/connect`, { method: 'POST' });
const token = mintConnectorToolToken('connector-partial-preview-project', 'connector-partial-preview-run');
composioDiscoveryRequestCounts = { authConfigs: 0, createdAuthConfigs: 0, toolkits: 0, tools: 0 };
const response = await jsonFetch(`${baseUrl}/api/tools/connectors/list`, {
headers: { Authorization: `Bearer ${token}` },
});
expect(response.status).toBe(200);
expect(response.body.connectors.map((connector: ConnectorDetail) => connector.id)).toEqual(['notion']);
expect(response.body.connectors[0].tools).toEqual(expect.arrayContaining([
expect.objectContaining({ name: 'notion.notion_search' }),
expect.objectContaining({ name: 'notion.notion_fetch_database' }),
expect.objectContaining({ name: 'notion.notion_get_page' }),
]));
expect(composioDiscoveryRequestCounts.tools).toBe(1);
});
it('filters connected connector tools by curated use case and returns curation metadata', async () => {
await new Promise((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve(undefined)));
server!.close((error?: Error) => (error ? reject(error) : resolve(undefined)));
});
mockComposioFetch({
authConfigs: [{ id: 'ac_slack', status: 'ENABLED', toolkit: { slug: 'slack' } }],
linkResponse: { connected_account_id: 'ca_slack', status: 'ACTIVE', account_label: 'slack@example.com' },
});
composioConnectorProvider.clearDiscoveryCache();
const started = await startServer({ port: 0, returnServer: true });
const started = await startServer({ port: 0, returnServer: true }) as StartedServer;
server = started.server;
baseUrl = started.url;
await jsonFetch(`${baseUrl}/api/connectors/composio/config`, {
@ -802,7 +1168,7 @@ describe('connector routes', () => {
});
expect(response.status).toBe(200);
expect(response.body.connectors.map((connector) => connector.id)).toEqual(['slack']);
expect(response.body.connectors.map((connector: ConnectorDetail) => connector.id)).toEqual(['slack']);
expect(response.body.connectors[0].tools).toEqual([
expect.objectContaining({
name: 'slack.slack_list_channels',

View file

@ -1,4 +1,3 @@
// @ts-nocheck
import { describe, expect, it, beforeAll, afterAll } from 'vitest';
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
@ -6,7 +5,7 @@ import path from 'node:path';
import { loadCraftSections } from '../src/craft.js';
let craftDir;
let craftDir: string;
beforeAll(async () => {
craftDir = await mkdtemp(path.join(tmpdir(), 'od-craft-test-'));

View file

@ -549,17 +549,23 @@ describe('deploy provider routes', () => {
headers: { 'content-type': 'application/json' },
});
}
if (url.includes(`/pages/projects/${expectedPagesProject}/domains?`) && method === 'GET') {
if (url.endsWith(`/pages/projects/${expectedPagesProject}/domains/demo.example.com`) && method === 'GET') {
domainListCount += 1;
const result =
domainListCount === 1
? []
: [{
name: 'demo.example.com',
status: domainListCount === 2 ? 'pending' : 'active',
validation_data: { txt_name: '_cf-custom-hostname.demo.example.com' },
verification_data: { cname: `${expectedPagesProject}.pages.dev` },
}];
if (domainListCount === 1) {
return new Response(JSON.stringify({
success: false,
errors: [{ message: 'Custom domain not found' }],
}), {
status: 404,
headers: { 'content-type': 'application/json' },
});
}
const result = {
name: 'demo.example.com',
status: domainListCount === 2 ? 'pending' : 'active',
validation_data: { txt_name: '_cf-custom-hostname.demo.example.com' },
verification_data: { cname: `${expectedPagesProject}.pages.dev` },
};
return new Response(JSON.stringify({ success: true, result }), {
status: 200,
headers: { 'content-type': 'application/json' },

View file

@ -870,7 +870,6 @@ describe('cloudflare pages deploys', () => {
dnsCreateAlreadyExists?: boolean;
dnsCreateRejectsComment?: boolean;
pagesDomains?: Array<Record<string, unknown>>;
pagesDomainPages?: Array<Array<Record<string, unknown>>>;
customHeadStatus?: number;
} = {}) {
const indexHash = cloudflarePagesAssetHash({
@ -974,19 +973,21 @@ describe('cloudflare pages deploys', () => {
headers: { 'content-type': 'application/json' },
});
}
if (url.includes('/pages/projects/demo-pages/domains?') && method === 'GET') {
const requestUrl = new URL(url);
const page = Number(requestUrl.searchParams.get('page') || '1');
const domainPages = options.pagesDomainPages;
const result = domainPages ? domainPages[page - 1] ?? [] : options.pagesDomains ?? [];
if (url.endsWith('/pages/projects/demo-pages/domains/demo.example.com') && method === 'GET') {
const result = (options.pagesDomains ?? [])
.find((domain) => domain.name === 'demo.example.com');
if (!result) {
return new Response(JSON.stringify({
success: false,
errors: [{ message: 'Custom domain not found' }],
}), {
status: 404,
headers: { 'content-type': 'application/json' },
});
}
return new Response(JSON.stringify({
success: true,
result,
result_info: {
page,
per_page: 100,
total_pages: domainPages?.length || 1,
},
}), {
status: 200,
headers: { 'content-type': 'application/json' },
@ -1637,9 +1638,12 @@ describe('cloudflare pages deploys', () => {
headers: { 'content-type': 'application/json' },
});
}
if (url.includes('/pages/projects/demo-pages/domains?') && method === 'GET') {
return new Response(JSON.stringify({ success: true, result: [] }), {
status: 200,
if (url.endsWith('/pages/projects/demo-pages/domains/demo.example.com') && method === 'GET') {
return new Response(JSON.stringify({
success: false,
errors: [{ message: 'Custom domain not found' }],
}), {
status: 404,
headers: { 'content-type': 'application/json' },
});
}
@ -1776,12 +1780,9 @@ describe('cloudflare pages deploys', () => {
expect(calls.filter((call) => call.url.endsWith('/zones/zone-1/dns_records') && call.method === 'POST')).toHaveLength(1);
});
it('finds existing Cloudflare Pages custom domains beyond the first page', async () => {
it('reads an existing Cloudflare Pages custom domain without unsupported list pagination', async () => {
const { calls, fetchMock } = createCustomDomainDeployMock({
pagesDomainPages: [
[{ name: 'other.example.com', status: 'active' }],
[{ name: 'demo.example.com', status: 'active' }],
],
pagesDomains: [{ name: 'demo.example.com', status: 'active' }],
});
vi.stubGlobal('fetch', fetchMock);
@ -1798,9 +1799,12 @@ describe('cloudflare pages deploys', () => {
},
});
const domainLookupUrls = calls
.filter((call) => call.url.includes('/pages/projects/demo-pages/domains?') && call.method === 'GET')
.map((call) => new URL(call.url).searchParams.get('page'));
expect(domainLookupUrls).toEqual(['1', '2']);
.filter((call) => call.url.includes('/pages/projects/demo-pages/domains/') && call.method === 'GET')
.map((call) => call.url);
expect(domainLookupUrls).toEqual([
'https://api.cloudflare.com/client/v4/accounts/account_123/pages/projects/demo-pages/domains/demo.example.com',
]);
expect(domainLookupUrls.every((url) => !url.includes('?'))).toBe(true);
expect(calls.some((call) => call.url.endsWith('/pages/projects/demo-pages/domains') && call.method === 'POST')).toBe(false);
});
@ -2112,9 +2116,12 @@ describe('cloudflare pages deploys', () => {
headers: { 'content-type': 'application/json' },
});
}
if (url.includes('/pages/projects/demo-pages/domains?') && method === 'GET') {
return new Response(JSON.stringify({ success: true, result: [] }), {
status: 200,
if (url.endsWith('/pages/projects/demo-pages/domains/demo.example.com') && method === 'GET') {
return new Response(JSON.stringify({
success: false,
errors: [{ message: 'Custom domain not found' }],
}), {
status: 404,
headers: { 'content-type': 'application/json' },
});
}
@ -2293,6 +2300,6 @@ describe('deployment link readiness', () => {
server: 'Vercel',
'set-cookie': '_vercel_sso_nonce=test',
});
expect(isVercelProtectedResponse({ headers }, 'Authentication Required')).toBe(true);
expect(isVercelProtectedResponse(new Response(null, { headers }), 'Authentication Required')).toBe(true);
});
});

View file

@ -0,0 +1,911 @@
// @ts-nocheck
// Tests for `apps/daemon/src/finalize-design.ts` — fills in across phases
// D-I. Phase D adds the truncation helper tests; phases E-I extend.
//
// Per memory `project_open_design_493_merged.md`: this file uses
// `import fs from 'node:fs'` (default import) so `vi.spyOn(fs, '<fn>')`
// can redefine properties on the underlying CJS exports object. ESM
// namespace import (`import * as fs from 'node:fs'`) gives a frozen
// Module Namespace Object that `vi.spyOn` cannot mutate.
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
import type http from 'node:http';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import {
closeDatabase,
insertConversation,
insertProject,
openDatabase,
upsertMessage,
} from '../src/db.js';
import { isSafeId, writeProjectFile } from '../src/projects.js';
import {
appendVersionedApiPath,
buildSynthesisPrompt,
callAnthropicWithRetry,
extractDesignMd,
finalizeDesignPackage,
FinalizePackageLockedError,
FinalizeUpstreamError,
resolveCurrentArtifact,
truncateTranscriptForPrompt,
} from '../src/finalize-design.js';
void appendVersionedApiPath;
// Touch the imports so the unused-import linter stays quiet on the scaffold.
void finalizeDesignPackage;
void FinalizePackageLockedError;
void FinalizeUpstreamError;
const PROJECT_ID = 'project-1';
let tempDir: string | null = null;
let projectsRoot: string | null = null;
afterEach(() => {
closeDatabase();
vi.restoreAllMocks();
if (tempDir) fs.rmSync(tempDir, { recursive: true, force: true });
tempDir = null;
projectsRoot = null;
});
function setupResolverFixture(): { db: any; projectsRoot: string } {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'od-finalize-'));
const db = openDatabase(tempDir);
insertProject(db, {
id: PROJECT_ID,
name: 'Project',
createdAt: 1,
updatedAt: 1,
});
projectsRoot = path.join(tempDir, 'projects');
fs.mkdirSync(path.join(projectsRoot, PROJECT_ID), { recursive: true });
return { db, projectsRoot };
}
function setActiveTab(db: any, name: string) {
db.prepare(
`INSERT INTO tabs (project_id, name, position, is_active) VALUES (?, ?, ?, 1)`,
).run(PROJECT_ID, name, 0);
}
const HEADER = JSON.stringify({
kind: 'header',
schemaVersion: 2,
projectId: 'proj-1',
exportedAt: '2026-05-07T14:00:00.000Z',
conversationCount: 1,
messageCount: 100,
});
function buildSyntheticJsonl(messageCount: number, perMessageBytes: number): string {
// Each message line is roughly `perMessageBytes` long after stringify.
const lines = [HEADER, JSON.stringify({ kind: 'conversation', id: 'c1', title: 't', createdAt: 1, updatedAt: 1 })];
const padBytes = Math.max(0, perMessageBytes - 80);
const filler = 'x'.repeat(padBytes);
for (let i = 0; i < messageCount; i += 1) {
lines.push(JSON.stringify({
kind: 'message',
id: `m${i}`,
role: 'user',
position: i,
blocks: [{ type: 'text', text: `msg-${i}-${filler}` }],
}));
}
return lines.join('\n') + '\n';
}
describe('truncateTranscriptForPrompt', () => {
it('returns the input verbatim when the JSONL fits under the 384 KiB cap', () => {
// 50 messages at ~100 bytes each = ~5 KB total; well under the cap.
const jsonl = buildSyntheticJsonl(50, 100);
expect(Buffer.byteLength(jsonl, 'utf8')).toBeLessThan(384 * 1024);
const out = truncateTranscriptForPrompt(jsonl);
expect(out).toBe(jsonl);
expect(out).not.toContain('"kind":"truncated"');
// Every message line round-trips.
for (let i = 0; i < 50; i += 1) {
expect(out).toContain(`"id":"m${i}"`);
}
});
it('head+tail truncates with a single marker line when the JSONL exceeds the 384 KiB cap', () => {
// 800 messages at ~1 KB each = ~800 KB total; comfortably above the cap.
const jsonl = buildSyntheticJsonl(800, 1024);
expect(Buffer.byteLength(jsonl, 'utf8')).toBeGreaterThan(384 * 1024);
const out = truncateTranscriptForPrompt(jsonl);
// Output is bounded by the cap (allow a small tolerance for the
// marker + reservation slack).
expect(Buffer.byteLength(out, 'utf8')).toBeLessThanOrEqual(384 * 1024);
// Header line survives.
expect(out.split('\n')[0]).toBe(HEADER);
// Exactly one truncation marker present, with a non-zero omittedBytes.
const markerMatches = out.match(/\{"kind":"truncated","reason":"size","omittedBytes":\d+\}/g);
expect(markerMatches).not.toBeNull();
expect(markerMatches).toHaveLength(1);
const omittedBytes = Number(markerMatches![0].match(/"omittedBytes":(\d+)/)![1]);
expect(omittedBytes).toBeGreaterThan(0);
// Both ends preserved: first message after header survives; last
// message before the trailing newline survives.
expect(out).toContain('"id":"m0"');
expect(out).toContain('"id":"m799"');
// Middle messages (e.g. m400) should NOT all survive — at least one
// must be omitted; otherwise we wouldn't have needed the marker.
const surviving = (out.match(/"id":"m\d+"/g) || []).map((s) => Number(s.match(/m(\d+)/)![1]));
expect(surviving.length).toBeLessThan(800);
expect(surviving).toContain(0);
expect(surviving).toContain(799);
});
});
describe('resolveCurrentArtifact', () => {
it('returns the active-tab artifact when its sidecar is present, even if a newer artifact exists elsewhere', async () => {
const { db, projectsRoot } = setupResolverFixture();
// Older artifact - active tab points here.
await writeProjectFile(projectsRoot, PROJECT_ID, 'pinned.html', '<p>pinned body</p>', {
artifactManifest: {
version: 1,
kind: 'html',
title: 'Pinned',
entry: 'pinned.html',
renderer: 'html',
exports: ['html'],
updatedAt: '2026-05-01T00:00:00.000Z',
},
});
// Newer artifact - NOT in the active tab.
await writeProjectFile(projectsRoot, PROJECT_ID, 'newer.html', '<p>newer body</p>', {
artifactManifest: {
version: 1,
kind: 'html',
title: 'Newer',
entry: 'newer.html',
renderer: 'html',
exports: ['html'],
updatedAt: '2026-05-07T00:00:00.000Z',
},
});
setActiveTab(db, 'pinned.html');
const out = await resolveCurrentArtifact(db, projectsRoot, PROJECT_ID);
expect(out).not.toBeNull();
expect(out!.name).toBe('pinned.html');
expect(out!.body).toBe('<p>pinned body</p>');
});
it('falls through to newest .artifact.json when active tab points at a non-artifact file', async () => {
const { db, projectsRoot } = setupResolverFixture();
// README.md - no artifact sidecar - active tab points here.
await writeProjectFile(projectsRoot, PROJECT_ID, 'README.md', '# notes\n');
// The actual artifact - NOT in active tab.
await writeProjectFile(projectsRoot, PROJECT_ID, 'design.html', '<p>design</p>', {
artifactManifest: {
version: 1,
kind: 'html',
title: 'Design',
entry: 'design.html',
renderer: 'html',
exports: ['html'],
updatedAt: '2026-05-07T00:00:00.000Z',
},
});
setActiveTab(db, 'README.md');
const out = await resolveCurrentArtifact(db, projectsRoot, PROJECT_ID);
expect(out).not.toBeNull();
expect(out!.name).toBe('design.html');
expect(out!.body).toBe('<p>design</p>');
});
it('returns null when no active tab and no .artifact.json sidecars exist', async () => {
const { db, projectsRoot } = setupResolverFixture();
// README.md only - no artifact sidecars anywhere.
await writeProjectFile(projectsRoot, PROJECT_ID, 'README.md', '# notes\n');
const out = await resolveCurrentArtifact(db, projectsRoot, PROJECT_ID);
expect(out).toBeNull();
});
// PR #832 P3 fix from @lefarcen: a malformed tabs row (e.g. an
// attacker with DB write access setting tabs.name = `../../../etc/passwd`)
// would otherwise cause path.join to compose a probe URL outside the
// project dir before readProjectFile's path-safety check kicked in.
// Post-fix: the resolver runs the tab name through validateProjectPath
// first; an invalid name falls through to the newest-artifact branch.
it('falls through (does not throw) when active tab name contains traversal segments', async () => {
const { db, projectsRoot } = setupResolverFixture();
// A real artifact exists.
await writeProjectFile(projectsRoot, PROJECT_ID, 'design.html', '<p>real</p>', {
artifactManifest: {
version: 1,
kind: 'html',
title: 'Design',
entry: 'design.html',
renderer: 'html',
exports: ['html'],
updatedAt: '2026-05-07T00:00:00.000Z',
},
});
// Inject a malformed tabs row directly via SQL — bypasses the
// production tab-creation code path which would normally validate.
db.prepare(
`INSERT INTO tabs (project_id, name, position, is_active) VALUES (?, ?, 0, 1)`,
).run(PROJECT_ID, '../../../etc/passwd');
const out = await resolveCurrentArtifact(db, projectsRoot, PROJECT_ID);
// The resolver must NOT throw and must NOT return the malformed name.
// It falls through to the newest-artifact branch and returns the
// real artifact instead.
expect(out).not.toBeNull();
expect(out!.name).toBe('design.html');
});
});
describe('buildSynthesisPrompt', () => {
const FIXED_NOW = new Date('2026-05-07T14:00:00.000Z');
const TRANSCRIPT_FIXTURE =
JSON.stringify({ kind: 'header', schemaVersion: 2, projectId: 'p1', messageCount: 2 }) +
'\n' +
JSON.stringify({ kind: 'message', id: 'm1', role: 'user', blocks: [{ type: 'text', text: 'hi' }] }) +
'\n' +
JSON.stringify({ kind: 'message', id: 'm2', role: 'assistant', blocks: [{ type: 'text', text: 'hello' }] }) +
'\n';
it('includes the transcript JSONL verbatim and the generation context', () => {
const out = buildSynthesisPrompt({
projectId: 'p1',
transcriptJsonl: TRANSCRIPT_FIXTURE,
transcriptMessageCount: 2,
designSystemId: 'shadcn',
designSystemBody: '# shadcn\nminimal\n',
artifact: { name: 'design.html', body: '<p>artifact</p>', manifest: null },
now: FIXED_NOW,
});
expect(out.systemPrompt).toContain('# DESIGN.md');
expect(out.systemPrompt).toContain('## Provenance');
expect(out.userPrompt).toContain('## Transcript (JSONL)');
expect(out.userPrompt).toContain(TRANSCRIPT_FIXTURE);
expect(out.userPrompt).toContain('## Active design system: shadcn');
expect(out.userPrompt).toContain('# shadcn\nminimal\n');
expect(out.userPrompt).toContain('## Current artifact: design.html');
expect(out.userPrompt).toContain('<p>artifact</p>');
expect(out.userPrompt).toContain('Generated at: 2026-05-07T14:00:00.000Z');
expect(out.userPrompt).toContain('Project ID: p1');
expect(out.userPrompt).toContain('Transcript message count: 2');
expect(out.userPrompt).toContain('Synthesize DESIGN.md per the system instructions.');
});
it('falls back to "none" + parenthetical when no design system is selected', () => {
const out = buildSynthesisPrompt({
projectId: 'p1',
transcriptJsonl: TRANSCRIPT_FIXTURE,
transcriptMessageCount: 2,
designSystemId: null,
designSystemBody: null,
artifact: { name: 'design.html', body: '<p>artifact</p>', manifest: null },
now: FIXED_NOW,
});
expect(out.userPrompt).toContain('## Active design system: none');
expect(out.userPrompt).toContain('(no design system selected for this project)');
});
it('falls back to "none" + parenthetical when no artifact is in scope', () => {
const out = buildSynthesisPrompt({
projectId: 'p1',
transcriptJsonl: TRANSCRIPT_FIXTURE,
transcriptMessageCount: 2,
designSystemId: 'shadcn',
designSystemBody: '# shadcn\n',
artifact: null,
now: FIXED_NOW,
});
expect(out.userPrompt).toContain('## Current artifact: none');
expect(out.userPrompt).toContain('(no artifact in scope for this finalize)');
});
});
describe('callAnthropicWithRetry', () => {
const baseParams = {
apiKey: 'sk-test-key',
baseUrl: 'https://api.anthropic.com',
model: 'claude-opus-4-7',
maxTokens: 16000,
systemPrompt: 'sys',
userPrompt: 'usr',
_sleepMs: async () => {},
};
function jsonResponse(status: number, body: any): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'content-type': 'application/json' },
});
}
function textResponse(status: number, body: string): Response {
return new Response(body, { status });
}
it('throws FinalizeUpstreamError(401) on auth failure (no retry on 4xx non-429)', async () => {
const fetchImpl = vi.fn(async () => textResponse(401, '{"error":{"type":"authentication_error","message":"invalid x-api-key"}}'));
await expect(callAnthropicWithRetry({ ...baseParams, fetchImpl })).rejects.toMatchObject({
name: 'FinalizeUpstreamError',
status: 401,
});
expect(fetchImpl).toHaveBeenCalledTimes(1);
});
it('retries once on 429 and resolves when the second response succeeds', async () => {
const ok = jsonResponse(200, { content: [{ type: 'text', text: 'DESIGN.md body' }], usage: { input_tokens: 1, output_tokens: 1 } });
const fetchImpl = vi
.fn<any, any>()
.mockResolvedValueOnce(textResponse(429, '{"error":"rate limited"}'))
.mockResolvedValueOnce(ok);
const response = await callAnthropicWithRetry({ ...baseParams, fetchImpl });
expect(response.status).toBe(200);
expect(fetchImpl).toHaveBeenCalledTimes(2);
});
it('throws FinalizeUpstreamError(503) when both attempts return 5xx', async () => {
const fetchImpl = vi
.fn<any, any>()
.mockResolvedValueOnce(textResponse(503, 'service unavailable'))
.mockResolvedValueOnce(textResponse(503, 'service unavailable'));
await expect(callAnthropicWithRetry({ ...baseParams, fetchImpl })).rejects.toMatchObject({
name: 'FinalizeUpstreamError',
status: 503,
});
expect(fetchImpl).toHaveBeenCalledTimes(2);
});
it('propagates AbortError from fetch when the signal is aborted', async () => {
const controller = new AbortController();
const fetchImpl = vi.fn(async (_url: string, init?: RequestInit) => {
// Mirror native fetch's behavior: throw AbortError when init.signal is aborted.
if (init?.signal?.aborted) {
const err = new Error('aborted');
err.name = 'AbortError';
throw err;
}
throw new Error('fetch should never be called with non-aborted signal in this test');
});
controller.abort();
await expect(
callAnthropicWithRetry({ ...baseParams, fetchImpl, signal: controller.signal }),
).rejects.toMatchObject({ name: 'AbortError' });
});
});
describe('extractDesignMd', () => {
it('concatenates text blocks in order', () => {
const payload = {
content: [
{ type: 'text', text: '# DESIGN.md\n## Summary\n' },
{ type: 'text', text: 'body continues here.\n' },
],
usage: { input_tokens: 1, output_tokens: 1 },
};
expect(extractDesignMd(payload)).toBe('# DESIGN.md\n## Summary\nbody continues here.\n');
});
it('throws FinalizeUpstreamError(502) when the response shape has no content array', () => {
expect(() => extractDesignMd({ unexpected: true })).toThrow(FinalizeUpstreamError);
try {
extractDesignMd({ unexpected: true });
} catch (err: any) {
expect(err.status).toBe(502);
}
});
it('throws FinalizeUpstreamError(502) when content array has zero text blocks', () => {
expect(() => extractDesignMd({ content: [{ type: 'tool_use', id: 'x', name: 'y', input: {} }] })).toThrow(FinalizeUpstreamError);
});
});
describe('finalizeDesignPackage (pipeline integration)', () => {
function setupPipeline(
opts: { designSystemId?: string | null; designSystemBody?: string | null } = {},
): { db: any; projectsRoot: string; designSystemsRoot: string } {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'od-finalize-pipe-'));
const designSystemsRoot = path.join(tempDir, 'design-systems');
fs.mkdirSync(designSystemsRoot, { recursive: true });
if (opts.designSystemId && opts.designSystemBody !== null) {
fs.mkdirSync(path.join(designSystemsRoot, opts.designSystemId), { recursive: true });
fs.writeFileSync(
path.join(designSystemsRoot, opts.designSystemId, 'DESIGN.md'),
opts.designSystemBody ?? '# default DESIGN.md\n',
);
}
const db = openDatabase(tempDir);
insertProject(db, {
id: PROJECT_ID,
name: 'Project',
designSystemId: opts.designSystemId === undefined ? 'shadcn' : opts.designSystemId,
createdAt: 1,
updatedAt: 1,
});
projectsRoot = path.join(tempDir, 'projects');
fs.mkdirSync(path.join(projectsRoot, PROJECT_ID), { recursive: true });
insertConversation(db, {
id: 'c1',
projectId: PROJECT_ID,
title: 'Greeting',
createdAt: 100,
updatedAt: 100,
});
upsertMessage(db, 'c1', {
id: 'm1',
role: 'user',
content: '',
events: [{ kind: 'text', text: 'design me a landing page' }],
});
upsertMessage(db, 'c1', {
id: 'm2',
role: 'assistant',
content: '',
events: [{ kind: 'text', text: 'here is the html' }],
});
return { db, projectsRoot, designSystemsRoot };
}
function happyFetch(designMd: string): typeof globalThis.fetch {
return vi.fn(async () =>
new Response(
JSON.stringify({
content: [{ type: 'text', text: designMd }],
usage: { input_tokens: 1234, output_tokens: 567 },
}),
{ status: 200, headers: { 'content-type': 'application/json' } },
),
) as any;
}
it('writes DESIGN.md atomically on the happy path', async () => {
const { db, projectsRoot, designSystemsRoot } = setupPipeline({
designSystemId: 'shadcn',
designSystemBody: '# shadcn\n## tone\nminimal, opinionated.\n',
});
const fetchImpl = happyFetch('# DESIGN.md\n## Summary\nA landing page.\n');
const result = await finalizeDesignPackage(db, projectsRoot, designSystemsRoot, PROJECT_ID, {
apiKey: 'sk-test',
baseUrl: 'https://api.anthropic.com',
model: 'claude-opus-4-7',
fetchImpl,
} as any);
expect(result.designMdPath).toBe(path.join(projectsRoot, PROJECT_ID, 'DESIGN.md'));
expect(fs.existsSync(result.designMdPath)).toBe(true);
expect(fs.readFileSync(result.designMdPath, 'utf8')).toBe(
'# DESIGN.md\n## Summary\nA landing page.\n',
);
// No leftover .tmp files.
const dirEntries = fs.readdirSync(path.join(projectsRoot, PROJECT_ID));
expect(dirEntries.filter((n) => n.startsWith('DESIGN.md.tmp.'))).toEqual([]);
// Lock is released.
expect(dirEntries).not.toContain('.finalize.lock');
});
it('response carries every documented field with correct types', async () => {
const { db, projectsRoot, designSystemsRoot } = setupPipeline({
designSystemId: 'shadcn',
designSystemBody: '# shadcn\n',
});
const fetchImpl = happyFetch('# DESIGN.md\nbody\n');
const result = await finalizeDesignPackage(db, projectsRoot, designSystemsRoot, PROJECT_ID, {
apiKey: 'sk-test',
baseUrl: 'https://api.anthropic.com',
model: 'claude-opus-4-7',
fetchImpl,
} as any);
expect(typeof result.designMdPath).toBe('string');
expect(typeof result.bytesWritten).toBe('number');
expect(result.bytesWritten).toBeGreaterThan(0);
expect(result.model).toBe('claude-opus-4-7');
expect(result.inputTokens).toBe(1234);
expect(result.outputTokens).toBe(567);
expect(result.artifact).toBeNull(); // no artifact seeded
expect(result.transcriptMessageCount).toBe(2);
expect(result.designSystemId).toBe('shadcn');
});
it('emits design system "none" in the prompt when no design_system_id is set', async () => {
const { db, projectsRoot, designSystemsRoot } = setupPipeline({
designSystemId: null,
});
const fetchImpl = vi.fn(async (_url: string, init: RequestInit) => {
// Capture the body for assertion.
const body = JSON.parse(init.body as string);
expect(body.system).toContain('# DESIGN.md');
expect(body.messages[0].content).toContain('## Active design system: none');
expect(body.messages[0].content).toContain(
'(no design system selected for this project)',
);
return new Response(
JSON.stringify({
content: [{ type: 'text', text: '# DESIGN.md\nbody\n' }],
usage: { input_tokens: 1, output_tokens: 1 },
}),
{ status: 200, headers: { 'content-type': 'application/json' } },
);
});
const result = await finalizeDesignPackage(db, projectsRoot, designSystemsRoot, PROJECT_ID, {
apiKey: 'sk-test',
baseUrl: 'https://api.anthropic.com',
model: 'claude-opus-4-7',
fetchImpl: fetchImpl as any,
} as any);
expect(result.designSystemId).toBeNull();
expect(fetchImpl).toHaveBeenCalledTimes(1);
});
it('throws FinalizePackageLockedError when .finalize.lock is already held', async () => {
const { db, projectsRoot, designSystemsRoot } = setupPipeline();
const lockPath = path.join(projectsRoot, PROJECT_ID, '.finalize.lock');
fs.writeFileSync(lockPath, '');
const fetchImpl = happyFetch('# DESIGN.md\nbody\n');
await expect(
finalizeDesignPackage(db, projectsRoot, designSystemsRoot, PROJECT_ID, {
apiKey: 'sk-test',
baseUrl: 'https://api.anthropic.com',
model: 'claude-opus-4-7',
fetchImpl,
} as any),
).rejects.toBeInstanceOf(FinalizePackageLockedError);
// DESIGN.md must NOT have been written; the pre-existing lock prevented it.
expect(fs.existsSync(path.join(projectsRoot, PROJECT_ID, 'DESIGN.md'))).toBe(false);
// The pre-existing lock must remain — we did not own it, so we must not unlink.
expect(fs.existsSync(lockPath)).toBe(true);
});
it('replaces an existing DESIGN.md atomically on a second finalize', async () => {
const { db, projectsRoot, designSystemsRoot } = setupPipeline();
const finalPath = path.join(projectsRoot, PROJECT_ID, 'DESIGN.md');
await finalizeDesignPackage(db, projectsRoot, designSystemsRoot, PROJECT_ID, {
apiKey: 'sk-test',
baseUrl: 'https://api.anthropic.com',
model: 'claude-opus-4-7',
fetchImpl: happyFetch('# DESIGN.md\nfirst run\n'),
} as any);
expect(fs.readFileSync(finalPath, 'utf8')).toBe('# DESIGN.md\nfirst run\n');
// Inject a sentinel between finalize calls.
fs.writeFileSync(finalPath, 'sentinel\n');
expect(fs.readFileSync(finalPath, 'utf8')).toBe('sentinel\n');
await finalizeDesignPackage(db, projectsRoot, designSystemsRoot, PROJECT_ID, {
apiKey: 'sk-test',
baseUrl: 'https://api.anthropic.com',
model: 'claude-opus-4-7',
fetchImpl: happyFetch('# DESIGN.md\nsecond run\n'),
} as any);
expect(fs.readFileSync(finalPath, 'utf8')).toBe('# DESIGN.md\nsecond run\n');
});
it('cleans up tmp file AND lock file on every error path', async () => {
const { db, projectsRoot, designSystemsRoot } = setupPipeline();
// Force a crash mid-write: spy on writeFileSync and throw on the
// DESIGN.md.tmp.* path. Other writeFileSync usages (e.g. transcript-
// export within this same call) must continue to work.
const realWrite = fs.writeFileSync;
vi.spyOn(fs, 'writeFileSync').mockImplementation((p: any, ...rest: any[]) => {
if (typeof p === 'string' && p.includes('DESIGN.md.tmp.')) {
throw new Error('disk full');
}
return (realWrite as any)(p, ...rest);
});
await expect(
finalizeDesignPackage(db, projectsRoot, designSystemsRoot, PROJECT_ID, {
apiKey: 'sk-test',
baseUrl: 'https://api.anthropic.com',
model: 'claude-opus-4-7',
fetchImpl: happyFetch('# DESIGN.md\nbody\n'),
} as any),
).rejects.toThrow(/disk full/);
const dirEntries = fs.readdirSync(path.join(projectsRoot, PROJECT_ID));
expect(dirEntries.filter((n) => n.startsWith('DESIGN.md.tmp.'))).toEqual([]);
expect(dirEntries).not.toContain('DESIGN.md');
expect(dirEntries).not.toContain('.finalize.lock');
});
it('uses the default https://api.anthropic.com baseUrl when baseUrl is omitted', async () => {
const { db, projectsRoot, designSystemsRoot } = setupPipeline();
const fetchImpl = vi.fn(async (url: string) => {
expect(url.startsWith('https://api.anthropic.com/v1/messages')).toBe(true);
return new Response(
JSON.stringify({
content: [{ type: 'text', text: '# DESIGN.md\n' }],
usage: { input_tokens: 1, output_tokens: 1 },
}),
{ status: 200, headers: { 'content-type': 'application/json' } },
);
});
await finalizeDesignPackage(db, projectsRoot, designSystemsRoot, PROJECT_ID, {
apiKey: 'sk-test',
// baseUrl deliberately omitted
model: 'claude-opus-4-7',
fetchImpl: fetchImpl as any,
} as any);
expect(fetchImpl).toHaveBeenCalledTimes(1);
});
// PR #832 P1 fix from @lefarcen: imported-folder projects (created via
// /api/import/folder) carry metadata.baseDir which redirects file IO to
// the user's actual folder. Pre-fix, the pipeline called projectDir()
// unconditionally so DESIGN.md landed in the hidden .od/projects/<id>
// dir instead of metadata.baseDir; the resolver also missed the user's
// real artifacts. Post-fix, both call sites use resolveProjectDir.
it('writes DESIGN.md under metadata.baseDir for imported-folder projects', async () => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'od-finalize-imported-'));
const designSystemsRoot = path.join(tempDir, 'design-systems');
fs.mkdirSync(designSystemsRoot, { recursive: true });
fs.mkdirSync(path.join(designSystemsRoot, 'shadcn'), { recursive: true });
fs.writeFileSync(path.join(designSystemsRoot, 'shadcn', 'DESIGN.md'), '# shadcn\n');
// The user's actual folder lives outside .od/projects/.
const userFolder = path.join(tempDir, 'user-imported-folder');
fs.mkdirSync(userFolder, { recursive: true });
const db = openDatabase(tempDir);
insertProject(db, {
id: PROJECT_ID,
name: 'Imported',
designSystemId: 'shadcn',
metadata: { baseDir: userFolder },
createdAt: 1,
updatedAt: 1,
});
projectsRoot = path.join(tempDir, 'projects');
fs.mkdirSync(path.join(projectsRoot, PROJECT_ID), { recursive: true });
insertConversation(db, {
id: 'c1',
projectId: PROJECT_ID,
title: 't',
createdAt: 100,
updatedAt: 100,
});
upsertMessage(db, 'c1', {
id: 'm1',
role: 'user',
content: '',
events: [{ kind: 'text', text: 'hi' }],
});
const result = await finalizeDesignPackage(db, projectsRoot, designSystemsRoot, PROJECT_ID, {
apiKey: 'sk-test',
baseUrl: 'https://api.anthropic.com',
model: 'claude-opus-4-7',
fetchImpl: happyFetch('# DESIGN.md\nimported folder body\n'),
} as any);
// DESIGN.md must land in the user's actual folder, NOT the hidden
// `.od/projects/<id>` dir.
expect(result.designMdPath).toBe(path.join(userFolder, 'DESIGN.md'));
expect(fs.existsSync(result.designMdPath)).toBe(true);
expect(fs.readFileSync(result.designMdPath, 'utf8')).toBe(
'# DESIGN.md\nimported folder body\n',
);
// The hidden daemon data dir should NOT have a DESIGN.md.
expect(fs.existsSync(path.join(projectsRoot, PROJECT_ID, 'DESIGN.md'))).toBe(false);
});
// PR #832 P1 fix from @lefarcen: network failures (DNS, ECONNREFUSED,
// fetch TypeError) used to fall through the route's catch-all and surface
// as 500 INTERNAL. Post-fix they are rewrapped as
// FinalizeUpstreamError(502, ...) inside the function so the route maps
// to 502 UPSTREAM_UNAVAILABLE with redacted details.
it('rewraps fetch network rejection as FinalizeUpstreamError(502)', async () => {
const { db, projectsRoot, designSystemsRoot } = setupPipeline();
const networkError = new TypeError('fetch failed');
(networkError as any).cause = { code: 'ENOTFOUND' };
const fetchImpl = vi.fn(async () => {
throw networkError;
});
await expect(
finalizeDesignPackage(db, projectsRoot, designSystemsRoot, PROJECT_ID, {
apiKey: 'sk-test',
baseUrl: 'https://nonexistent.invalid',
model: 'claude-opus-4-7',
fetchImpl: fetchImpl as any,
} as any),
).rejects.toMatchObject({
name: 'FinalizeUpstreamError',
status: 502,
});
});
it('rewraps 200 with non-JSON body as FinalizeUpstreamError(502)', async () => {
const { db, projectsRoot, designSystemsRoot } = setupPipeline();
// 200 OK with text/html body — response.json() will throw SyntaxError.
const fetchImpl = vi.fn(async () =>
new Response('<html>upstream proxy error</html>', {
status: 200,
headers: { 'content-type': 'text/html' },
}),
);
await expect(
finalizeDesignPackage(db, projectsRoot, designSystemsRoot, PROJECT_ID, {
apiKey: 'sk-test',
baseUrl: 'https://api.anthropic.com',
model: 'claude-opus-4-7',
fetchImpl: fetchImpl as any,
} as any),
).rejects.toMatchObject({
name: 'FinalizeUpstreamError',
status: 502,
});
});
});
// HTTP-layer tests for the route handler's validation. Boot the daemon
// once via startServer({ port: 0, returnServer: true }) and send real
// HTTP POSTs. These tests exercise the validation branches the function-
// level tests above cannot reach (validateExternalApiBaseUrl is a
// closure inside startServer, not exported).
describe('POST /api/projects/:id/finalize/anthropic — HTTP-layer validation', () => {
let server: http.Server;
let serverBaseUrl: string;
beforeAll(async () => {
const { startServer } = await import('../src/server.js');
const started = (await startServer({ port: 0, returnServer: true })) as unknown as {
url: string;
server: http.Server;
};
serverBaseUrl = started.url;
server = started.server;
});
afterAll(async () => {
await new Promise<void>((resolve) => server.close(() => resolve()));
});
function postJson(id: string, body: unknown): Promise<Response> {
return fetch(`${serverBaseUrl}/api/projects/${id}/finalize/anthropic`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
});
}
it('400 BAD_REQUEST when baseUrl is not a valid URL (test #13)', async () => {
const res = await postJson('p1', {
apiKey: 'sk-test',
baseUrl: 'not-a-url',
model: 'claude-opus-4-7',
});
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error.code).toBe('BAD_REQUEST');
});
it('403 FORBIDDEN when baseUrl points at a private internal IP (test #14)', async () => {
// validateBaseUrl explicitly allows loopback (for local OpenAI-compatible
// servers) but blocks private internal IPs (10/8, 172.16/12, 192.168/16,
// fc00::/7, fe80::/10) — see apps/daemon/src/connectionTest.ts:158-185.
const res = await postJson('p1', {
apiKey: 'sk-test',
baseUrl: 'http://10.0.0.1',
model: 'claude-opus-4-7',
});
expect(res.status).toBe(403);
const body = await res.json();
expect(body.error.code).toBe('FORBIDDEN');
});
it('400 BAD_REQUEST when apiKey is missing (test #15)', async () => {
const res = await postJson('p1', {
// apiKey deliberately omitted
baseUrl: 'https://api.anthropic.com',
model: 'claude-opus-4-7',
});
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error.code).toBe('BAD_REQUEST');
expect(body.error.message.toLowerCase()).toContain('apikey');
});
it('400 BAD_REQUEST when :id contains characters outside the safe-id regex (test #16)', async () => {
// isSafeId allows only [A-Za-z0-9._-]{1,128}. An id like `bad!id`
// contains `!` and must be rejected before any DB or filesystem work.
const res = await postJson('bad!id', {
apiKey: 'sk-test',
baseUrl: 'https://api.anthropic.com',
model: 'claude-opus-4-7',
});
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error.code).toBe('BAD_REQUEST');
expect(body.error.message.toLowerCase()).toContain('project id');
});
});
// Path-traversal regression coverage flagged by @lefarcen on PR #832.
//
// The threat: pre-fix `isSafeId` regex `/^[A-Za-z0-9._-]{1,128}$/` allowed
// pure-dot ids (`.`, `..`, `...`) because `.` is in the character class.
// `projectDir` and `resolveProjectDir` both delegated to `isSafeId` so they
// inherited the hole; an id of `..` would resolve to the PARENT of
// `.od/projects/` via `path.join`. The HTTP layer happens to reject this
// today because Express normalizes `%2e%2e` to `..` and collapses the
// path before the route handler sees it (yielding 404), but a direct CLI
// or scripted caller would still reach the function and trigger the
// traversal — and a stored project row whose id is `..` would also slip
// past `isSafeId` checks downstream of the handler.
//
// Unit-test isSafeId directly so the hole stays closed regardless of
// which call site is exercised.
describe('isSafeId — path-traversal regression', () => {
it('rejects a single dot', () => {
expect(isSafeId('.')).toBe(false);
});
it('rejects a double dot (parent-traversal)', () => {
expect(isSafeId('..')).toBe(false);
});
it('rejects three or more dots', () => {
expect(isSafeId('...')).toBe(false);
expect(isSafeId('....')).toBe(false);
});
it('rejects characters outside [A-Za-z0-9._-]', () => {
expect(isSafeId('bad!id')).toBe(false);
expect(isSafeId('a/b')).toBe(false);
expect(isSafeId('a\\b')).toBe(false);
expect(isSafeId(' leading-space')).toBe(false);
});
it('rejects empty string and >128 chars', () => {
expect(isSafeId('')).toBe(false);
expect(isSafeId('a'.repeat(129))).toBe(false);
});
it('rejects non-string inputs', () => {
expect(isSafeId(null as any)).toBe(false);
expect(isSafeId(undefined as any)).toBe(false);
expect(isSafeId(42 as any)).toBe(false);
});
it('accepts valid ids including dots in the middle', () => {
expect(isSafeId('project-1')).toBe(true);
expect(isSafeId('my-project.v2')).toBe(true);
expect(isSafeId('818cf7a8-8399-4220-a507-07802d8842a8')).toBe(true);
expect(isSafeId('a')).toBe(true);
expect(isSafeId('a.b.c')).toBe(true); // mixed-content with dots is fine
});
});

View file

@ -1,11 +1,17 @@
// @ts-nocheck
import { test } from 'vitest';
import assert from 'node:assert/strict';
import { createJsonEventStreamHandler } from '../src/json-event-stream.js';
type JsonStreamEvent = Record<string, unknown>;
function collectEvents(kind: string) {
const events: JsonStreamEvent[] = [];
const handler = createJsonEventStreamHandler(kind, (event) => events.push(event));
return { events, handler };
}
test('opencode json stream emits text and usage events', () => {
const events = [];
const handler = createJsonEventStreamHandler('opencode', (event) => events.push(event));
const { events, handler } = collectEvents('opencode');
handler.feed(
'{"type":"step_start","sessionID":"ses-1","part":{"type":"step-start"}}\n' +
@ -31,8 +37,7 @@ test('opencode json stream emits text and usage events', () => {
});
test('opencode json stream emits tool events', () => {
const events = [];
const handler = createJsonEventStreamHandler('opencode', (event) => events.push(event));
const { events, handler } = collectEvents('opencode');
handler.feed(
JSON.stringify({
@ -56,8 +61,7 @@ test('opencode json stream emits tool events', () => {
});
test('opencode json stream emits structured errors as error events', () => {
const events = [];
const handler = createJsonEventStreamHandler('opencode', (event) => events.push(event));
const { events, handler } = collectEvents('opencode');
const errorLine = JSON.stringify({
type: 'error',
@ -71,8 +75,7 @@ test('opencode json stream emits structured errors as error events', () => {
});
test('opencode json stream preserves nested error messages', () => {
const events = [];
const handler = createJsonEventStreamHandler('opencode', (event) => events.push(event));
const { events, handler } = collectEvents('opencode');
const errorLine = JSON.stringify({
type: 'error',
@ -86,8 +89,7 @@ test('opencode json stream preserves nested error messages', () => {
});
test('opencode json stream falls back to error name when data has no message', () => {
const events = [];
const handler = createJsonEventStreamHandler('opencode', (event) => events.push(event));
const { events, handler } = collectEvents('opencode');
const errorLine = JSON.stringify({
type: 'error',
@ -101,8 +103,7 @@ test('opencode json stream falls back to error name when data has no message', (
});
test('unknown json stream lines become raw events', () => {
const events = [];
const handler = createJsonEventStreamHandler('opencode', (event) => events.push(event));
const { events, handler } = collectEvents('opencode');
handler.feed('not-json\n');
handler.flush();
@ -118,8 +119,7 @@ test('unknown json stream lines become raw events', () => {
// which the chat UI doesn't render — the run looked like a fast clean
// success while the user actually got nothing back.
test('opencode json stream surfaces error frames as proper error events (regression of #691)', () => {
const events = [];
const handler = createJsonEventStreamHandler('opencode', (event) => events.push(event));
const { events, handler } = collectEvents('opencode');
const errorLine = JSON.stringify({
type: 'error',
@ -140,8 +140,7 @@ test('opencode json stream surfaces error frames as proper error events (regress
});
test('opencode json stream falls back to error.name when error.data.message is absent', () => {
const events = [];
const handler = createJsonEventStreamHandler('opencode', (event) => events.push(event));
const { events, handler } = collectEvents('opencode');
const errorLine = JSON.stringify({
type: 'error',
@ -159,8 +158,7 @@ test('opencode json stream falls back to error.name when error.data.message is a
});
test('opencode json stream falls back to a generic message when error has no usable detail', () => {
const events = [];
const handler = createJsonEventStreamHandler('opencode', (event) => events.push(event));
const { events, handler } = collectEvents('opencode');
const errorLine = JSON.stringify({ type: 'error', error: {} });
handler.feed(errorLine + '\n');
@ -175,8 +173,7 @@ test('opencode json stream falls back to a generic message when error has no usa
});
test('gemini stream emits init text and usage events', () => {
const events = [];
const handler = createJsonEventStreamHandler('gemini', (event) => events.push(event));
const { events, handler } = collectEvents('gemini');
handler.feed(
JSON.stringify({ type: 'init', session_id: 'gm-1', model: 'gemini-3-flash-preview' }) + '\n' +
@ -201,8 +198,7 @@ test('gemini stream emits init text and usage events', () => {
});
test('cursor stream emits partial text once and usage events', () => {
const events = [];
const handler = createJsonEventStreamHandler('cursor-agent', (event) => events.push(event));
const { events, handler } = collectEvents('cursor-agent');
handler.feed(
JSON.stringify({ type: 'system', subtype: 'init', model: 'GPT-5 Mini' }) + '\n' +
@ -244,8 +240,7 @@ test('cursor stream emits partial text once and usage events', () => {
});
test('cursor stream emits suffix when final assistant extends partial text', () => {
const events = [];
const handler = createJsonEventStreamHandler('cursor-agent', (event) => events.push(event));
const { events, handler } = collectEvents('cursor-agent');
handler.feed(
JSON.stringify({
@ -268,8 +263,7 @@ test('cursor stream emits suffix when final assistant extends partial text', ()
});
test('cursor stream de-duplicates cumulative timestamped assistant chunks', () => {
const events = [];
const handler = createJsonEventStreamHandler('cursor-agent', (event) => events.push(event));
const { events, handler } = collectEvents('cursor-agent');
handler.feed(
JSON.stringify({
@ -299,8 +293,7 @@ test('cursor stream de-duplicates cumulative timestamped assistant chunks', () =
});
test('codex json stream emits status text and usage events', () => {
const events = [];
const handler = createJsonEventStreamHandler('codex', (event) => events.push(event));
const { events, handler } = collectEvents('codex');
handler.feed(
JSON.stringify({ type: 'thread.started', thread_id: 'thr-1' }) + '\n' +
@ -326,8 +319,7 @@ test('codex json stream emits status text and usage events', () => {
});
test('codex json stream emits structured errors once', () => {
const events = [];
const handler = createJsonEventStreamHandler('codex', (event) => events.push(event));
const { events, handler } = collectEvents('codex');
handler.feed(
JSON.stringify({
@ -353,8 +345,7 @@ test('codex json stream emits structured errors once', () => {
});
test('codex json stream emits command execution tool events', () => {
const events = [];
const handler = createJsonEventStreamHandler('codex', (event) => events.push(event));
const { events, handler } = collectEvents('codex');
handler.feed(
JSON.stringify({
@ -400,8 +391,7 @@ test('codex json stream emits command execution tool events', () => {
});
test('unhandled structured events fall back to raw', () => {
const events = [];
const handler = createJsonEventStreamHandler('codex', (event) => events.push(event));
const { events, handler } = collectEvents('codex');
handler.feed(JSON.stringify({ type: 'unhandled.event', foo: 'bar' }) + '\n');

View file

@ -1,7 +1,12 @@
// @ts-nocheck
import { describe, expect, it } from 'vitest';
import { lintArtifact } from '../src/lint-artifact.js';
import { lintArtifact, type LintFinding } from '../src/lint-artifact.js';
function requiredFinding(findings: LintFinding[], id: string): LintFinding {
const hit = findings.find((finding) => finding.id === id);
if (!hit) throw new Error(`expected lint finding ${id}`);
return hit;
}
describe('ai-default-indigo', () => {
it('flags solid #6366f1 used as accent', () => {
@ -12,8 +17,7 @@ describe('ai-default-indigo', () => {
<button class="cta">Get started</button>
`;
const findings = lintArtifact(html);
const hit = findings.find((f) => f.id === 'ai-default-indigo');
expect(hit).toBeDefined();
const hit = requiredFinding(findings, 'ai-default-indigo');
expect(hit.severity).toBe('P0');
});
@ -35,8 +39,7 @@ describe('ai-default-indigo', () => {
])('flags solid %s (%s) as a documented cardinal-sin accent', (hex) => {
const html = `<div style="background: ${hex}">Hi</div>`;
const findings = lintArtifact(html);
const hit = findings.find((f) => f.id === 'ai-default-indigo');
expect(hit).toBeDefined();
const hit = requiredFinding(findings, 'ai-default-indigo');
expect(hit.severity).toBe('P0');
});
@ -476,8 +479,7 @@ describe('all-caps-no-tracking', () => {
<span class="eyebrow">New</span>
`;
const findings = lintArtifact(html);
const hit = findings.find((f) => f.id === 'all-caps-no-tracking');
expect(hit).toBeDefined();
const hit = requiredFinding(findings, 'all-caps-no-tracking');
expect(hit.severity).toBe('P1');
});
@ -529,8 +531,7 @@ describe('all-caps-no-tracking', () => {
<span class="eyebrow">New</span>
`;
const findings = lintArtifact(html);
const hit = findings.find((f) => f.id === 'all-caps-no-tracking');
expect(hit).toBeDefined();
const hit = requiredFinding(findings, 'all-caps-no-tracking');
expect(hit.severity).toBe('P1');
});
@ -572,8 +573,7 @@ describe('all-caps-no-tracking', () => {
// ALL CAPS the typography rule prohibits without tracking.
const html = `<span style="text-transform: uppercase">NEW</span>`;
const findings = lintArtifact(html);
const hit = findings.find((f) => f.id === 'all-caps-no-tracking');
expect(hit).toBeDefined();
const hit = requiredFinding(findings, 'all-caps-no-tracking');
expect(hit.severity).toBe('P1');
});
@ -1169,16 +1169,14 @@ describe('trust-gradient', () => {
// past unflagged. The new `trust-gradient` rule closes that gap.
const html = `<div style="background: linear-gradient(90deg, #3b82f6, #06b6d4)">Hi</div>`;
const findings = lintArtifact(html);
const hit = findings.find((f) => f.id === 'trust-gradient');
expect(hit).toBeDefined();
const hit = requiredFinding(findings, 'trust-gradient');
expect(hit.severity).toBe('P0');
});
it('flags a blue→cyan two-stop gradient with keyword stops', () => {
const html = `<div style="background: linear-gradient(90deg, blue, cyan)">Hi</div>`;
const findings = lintArtifact(html);
const hit = findings.find((f) => f.id === 'trust-gradient');
expect(hit).toBeDefined();
const hit = requiredFinding(findings, 'trust-gradient');
expect(hit.severity).toBe('P0');
});

View file

@ -1,4 +1,3 @@
// @ts-nocheck
import { mkdir, rm, writeFile } from 'node:fs/promises';
import http from 'node:http';
import path from 'node:path';
@ -10,18 +9,33 @@ import { startServer } from '../src/server.js';
import { connectorService, ConnectorServiceError } from '../src/connectors/service.js';
import { CHAT_TOOL_ENDPOINTS, CHAT_TOOL_OPERATIONS, toolTokenRegistry } from '../src/tool-tokens.js';
type StartedServer = { server: http.Server; url: string };
type JsonObject = Record<string, any>;
type JsonFetchResult<TBody extends JsonObject = JsonObject> = { status: number; body: TBody };
type TextFetchResult = { status: number; headers: Headers; body: string };
type RawHttpJsonFetchResult<TBody extends JsonObject = JsonObject> = {
status: number | undefined;
headers: http.IncomingHttpHeaders;
body: TBody;
};
type ProjectEvent = { event: string; data: any };
type ProjectEventStream = {
waitFor(predicate: (event: ProjectEvent) => boolean, timeoutMs?: number): Promise<ProjectEvent>;
close(): Promise<void>;
};
const here = path.dirname(fileURLToPath(import.meta.url));
const projectRoot = path.resolve(here, '../../..');
const serverRuntimeDataRoot = process.env.OD_DATA_DIR
? path.resolve(projectRoot, process.env.OD_DATA_DIR)
: path.join(projectRoot, '.od');
let server;
let baseUrl;
const projectIds = [];
let server: http.Server | undefined;
let baseUrl: string;
const projectIds: string[] = [];
beforeEach(async () => {
const started = await startServer({ port: 0, returnServer: true });
const started = (await startServer({ port: 0, returnServer: true })) as StartedServer;
server = started.server;
baseUrl = started.url;
});
@ -30,7 +44,7 @@ afterEach(async () => {
vi.restoreAllMocks();
await new Promise((resolve, reject) => {
if (!server) return resolve(undefined);
server.close((error) => (error ? reject(error) : resolve(undefined)));
server.close((error?: Error) => (error ? reject(error) : resolve(undefined)));
});
server = undefined;
toolTokenRegistry.clear();
@ -48,6 +62,10 @@ function uniqueProjectId() {
return id;
}
function readAutoSafety(reason = 'test read-only connector fixture') {
return { sideEffect: 'read' as const, approval: 'auto' as const, reason };
}
function validCreateInput(title = 'Tool Route Live Artifact') {
return {
title,
@ -62,26 +80,29 @@ function validCreateInput(title = 'Tool Route Live Artifact') {
};
}
async function jsonFetch(url, init) {
async function jsonFetch<TBody extends JsonObject = JsonObject>(url: string | URL, init?: RequestInit): Promise<JsonFetchResult<TBody>> {
const response = await fetch(url, init);
return { status: response.status, body: await response.json() };
return { status: response.status, body: (await response.json()) as TBody };
}
async function textFetch(url, init) {
async function textFetch(url: string | URL, init?: RequestInit): Promise<TextFetchResult> {
const response = await fetch(url, init);
return { status: response.status, headers: response.headers, body: await response.text() };
}
async function createProject(projectId) {
async function createProject(projectId: string): Promise<JsonFetchResult> {
const response = await fetch(`${baseUrl}/api/projects`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: projectId, name: projectId }),
});
return { status: response.status, body: await response.json() };
return { status: response.status, body: (await response.json()) as JsonObject };
}
async function rawHttpJsonFetch(url, { headers = {}, method = 'GET' } = {}) {
async function rawHttpJsonFetch<TBody extends JsonObject = JsonObject>(
url: string,
{ headers = {}, method = 'GET' }: { headers?: http.OutgoingHttpHeaders; method?: string } = {},
): Promise<RawHttpJsonFetchResult<TBody>> {
const parsed = new URL(url);
return new Promise((resolve, reject) => {
const req = http.request(
@ -100,7 +121,7 @@ async function rawHttpJsonFetch(url, { headers = {}, method = 'GET' } = {}) {
});
res.on('end', () => {
try {
resolve({ status: res.statusCode, headers: res.headers, body: JSON.parse(body) });
resolve({ status: res.statusCode, headers: res.headers, body: JSON.parse(body) as TBody });
} catch (error) {
reject(error);
}
@ -112,9 +133,9 @@ async function rawHttpJsonFetch(url, { headers = {}, method = 'GET' } = {}) {
});
}
async function writeProjectJson(projectId, name, value) {
async function writeProjectJson(projectId: string, name: string, value: JsonObject): Promise<void> {
const candidates = [path.join(serverRuntimeDataRoot, 'projects', projectId)];
let lastError;
let lastError: unknown;
let wrote = false;
for (const dir of candidates) {
try {
@ -129,7 +150,7 @@ async function writeProjectJson(projectId, name, value) {
throw lastError;
}
async function openProjectEvents(projectId) {
async function openProjectEvents(projectId: string): Promise<ProjectEventStream> {
const response = await fetch(`${baseUrl}/api/projects/${encodeURIComponent(projectId)}/events`, {
headers: { Accept: 'text/event-stream' },
});
@ -140,7 +161,7 @@ async function openProjectEvents(projectId) {
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
const events = [];
const events: ProjectEvent[] = [];
const pump = (async () => {
while (true) {
@ -153,7 +174,7 @@ async function openProjectEvents(projectId) {
buffer = buffer.slice(boundary + 2);
boundary = buffer.indexOf('\n\n');
if (!raw.trim() || raw.startsWith(':')) continue;
const evt = { event: 'message', data: '' };
const evt: ProjectEvent = { event: 'message', data: '' };
for (const line of raw.split('\n')) {
if (line.startsWith('event: ')) evt.event = line.slice(7);
if (line.startsWith('data: ')) evt.data += line.slice(6);
@ -167,7 +188,7 @@ async function openProjectEvents(projectId) {
})();
return {
async waitFor(predicate, timeoutMs = 5_000) {
async waitFor(predicate: (event: ProjectEvent) => boolean, timeoutMs = 5_000) {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
const match = events.find(predicate);
@ -183,7 +204,7 @@ async function openProjectEvents(projectId) {
};
}
function mintToolToken(projectId, runId, overrides = {}) {
function mintToolToken(projectId: string, runId: string, overrides: Partial<Parameters<typeof toolTokenRegistry.mint>[0]> = {}) {
return toolTokenRegistry.mint({
projectId,
runId,
@ -243,14 +264,14 @@ describe('live artifact tool routes', () => {
ok: true,
connectorId: 'monet',
toolName: 'monet.metrics',
safety: { sideEffect: 'read', approval: 'auto' },
safety: readAutoSafety(),
output: { title: 'Open bugs', owner: '7' },
})
.mockResolvedValueOnce({
ok: true,
connectorId: 'monet',
toolName: 'monet.metrics',
safety: { sideEffect: 'read', approval: 'auto' },
safety: readAutoSafety(),
output: { title: 'Open bugs', owner: '8' },
});
@ -485,7 +506,7 @@ describe('live artifact tool routes', () => {
ok: true,
connectorId: 'monet',
toolName: 'monet.metrics',
safety: { sideEffect: 'read', approval: 'auto' },
safety: readAutoSafety(),
output: { title: 'Should not refresh', owner: '0' },
});
@ -534,7 +555,7 @@ describe('live artifact tool routes', () => {
ok: true,
connectorId: 'monet',
toolName: 'monet.metrics',
safety: { sideEffect: 'read', approval: 'auto' },
safety: readAutoSafety(),
output: { title: 'Default refresh', owner: '9' },
});

View file

@ -1,4 +1,3 @@
// @ts-nocheck
import { describe, expect, it } from 'vitest';
import { extractRelativeRefs } from '../src/mcp.js';

View file

@ -1,12 +1,45 @@
// @ts-nocheck
import http from 'node:http';
import type { AddressInfo } from 'node:net';
import express from 'express';
import type { Express } from 'express';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { getArtifact, fetchProjectFile } from '../src/mcp.js';
// A minimal mock of the daemon's project file endpoints. Tests control
// the file list and per-file response via the opts object.
function makeDaemonApp(opts = {}) {
interface DaemonAppOpts {
files?: Array<{ name: string }>;
fileContent?: string;
contentType?: string;
contentLength?: number | null;
}
interface Harness {
server: http.Server;
baseUrl: string;
}
interface TextContent {
type: string;
text: string;
}
interface ArtifactBody {
truncated: boolean;
files: unknown[];
}
function firstText(content: TextContent[]): string {
const item = content[0];
if (item == null) throw new Error('expected MCP text content');
return item.text;
}
function parseArtifactBody(text: string): ArtifactBody {
return JSON.parse(text) as ArtifactBody;
}
function makeDaemonApp(opts: DaemonAppOpts = {}): Express {
const { files = [], fileContent = 'body {}', contentType = 'text/css', contentLength = null } = opts;
const app = express();
@ -19,7 +52,7 @@ function makeDaemonApp(opts = {}) {
app.get('/api/projects/:id/files', (_req, res) => res.json({ files }));
app.get('/api/projects/:id/raw/*', (_req, res) => {
const headers = { 'content-type': contentType };
const headers: Record<string, string> = { 'content-type': contentType };
if (contentLength != null) headers['content-length'] = String(contentLength);
res.set(headers).send(fileContent);
});
@ -27,11 +60,11 @@ function makeDaemonApp(opts = {}) {
return app;
}
function startServer(app) {
function startServer(app: Express): Promise<Harness> {
return new Promise((resolve) => {
const tmp = http.createServer();
tmp.listen(0, '127.0.0.1', () => {
const { port } = tmp.address();
const { port } = tmp.address() as AddressInfo;
tmp.close(() => {
const server = app.listen(port, '127.0.0.1', () =>
resolve({ server, baseUrl: `http://127.0.0.1:${port}` }),
@ -44,8 +77,8 @@ function startServer(app) {
const PROJECT_ID = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa';
describe('getArtifact file-count cap (MAX_FILES = 200)', () => {
let server;
let baseUrl;
let server: http.Server;
let baseUrl: string;
const fileList = Array.from({ length: 250 }, (_, i) => ({ name: `file${i}.css` }));
@ -59,15 +92,15 @@ describe('getArtifact file-count cap (MAX_FILES = 200)', () => {
it('caps at 200 files and sets truncated: true when the project has 250 files', async () => {
const result = await getArtifact(baseUrl, PROJECT_ID, 'index.html', 'all', 10_000_000);
const body = JSON.parse(result.content[0].text);
const body = parseArtifactBody(firstText(result.content));
expect(body.truncated).toBe(true);
expect(body.files.length).toBe(200);
});
});
describe('getArtifact maxBytes cap', () => {
let server;
let baseUrl;
let server: http.Server;
let baseUrl: string;
// 10 files, each 200 bytes. With maxBytes=400 the third loop iteration
// finds totalTextBytes >= maxBytes and sets truncated: true.
@ -84,15 +117,15 @@ describe('getArtifact maxBytes cap', () => {
it('stops fetching and sets truncated: true when byte cap is reached', async () => {
const result = await getArtifact(baseUrl, PROJECT_ID, 'index.html', 'all', 400);
const body = JSON.parse(result.content[0].text);
const body = parseArtifactBody(firstText(result.content));
expect(body.truncated).toBe(true);
expect(body.files.length).toBeLessThan(10);
});
});
describe('fetchProjectFile per-file size pre-check', () => {
let server;
let baseUrl;
let server: http.Server;
let baseUrl: string;
beforeAll(async () => {
const r = await startServer(
@ -113,13 +146,13 @@ describe('fetchProjectFile per-file size pre-check', () => {
it('succeeds and returns content when remainingBytes is sufficient', async () => {
const file = await fetchProjectFile(baseUrl, PROJECT_ID, 'styles.css', 20_000);
expect(file.binary).toBe(false);
expect(file.content.length).toBe(10_000);
expect(file.content?.length).toBe(10_000);
});
});
describe('getArtifact truncated: true when per-file content-length pre-check fires (include=all)', () => {
let server;
let baseUrl;
let server: http.Server;
let baseUrl: string;
// 5 files, each 250 bytes with explicit content-length.
// maxBytes=400: file0 (remaining=400, size=250) fetches fine.
@ -140,7 +173,7 @@ describe('getArtifact truncated: true when per-file content-length pre-check fir
it('sets truncated: true even when totalTextBytes never reaches maxBytes', async () => {
const result = await getArtifact(baseUrl, PROJECT_ID, 'index.html', 'all', 400);
const body = JSON.parse(result.content[0].text);
const body = parseArtifactBody(firstText(result.content));
expect(body.truncated).toBe(true);
expect(body.files.length).toBe(1);
});

View file

@ -1,12 +1,23 @@
// @ts-nocheck
import http from 'node:http';
import type { AddressInfo } from 'node:net';
import express from 'express';
import type { Express } from 'express';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { getFile } from '../src/mcp.js';
const PROJECT_ID = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa';
function makeDaemonApp(text, contentType = 'text/plain') {
interface Harness {
server: http.Server;
baseUrl: string;
}
interface TextContent {
type: string;
text: string;
}
function makeDaemonApp(text: string, contentType = 'text/plain'): Express {
const app = express();
app.get('/api/projects/:id/raw/*', (_req, res) => {
res.set({ 'content-type': contentType }).send(text);
@ -14,11 +25,11 @@ function makeDaemonApp(text, contentType = 'text/plain') {
return app;
}
function startServer(app) {
function startServer(app: Express): Promise<Harness> {
return new Promise((resolve) => {
const tmp = http.createServer();
tmp.listen(0, '127.0.0.1', () => {
const { port } = tmp.address();
const { port } = tmp.address() as AddressInfo;
tmp.close(() => {
const server = app.listen(port, '127.0.0.1', () =>
resolve({ server, baseUrl: `http://127.0.0.1:${port}` }),
@ -30,9 +41,19 @@ function startServer(app) {
const FIVE_HUNDRED_LINES = Array.from({ length: 500 }, (_, i) => `line ${i + 1}`).join('\n');
function contentTexts(content: TextContent[]): string[] {
return content.map((c) => c.text);
}
function lastText(parts: string[]): string {
const text = parts.at(-1);
if (text == null) throw new Error('expected MCP text content');
return text;
}
describe('getFile offset/limit slicing', () => {
let server;
let baseUrl;
let server: http.Server;
let baseUrl: string;
beforeAll(async () => {
const r = await startServer(makeDaemonApp(FIVE_HUNDRED_LINES, 'text/plain'));
@ -44,9 +65,9 @@ describe('getFile offset/limit slicing', () => {
it('default args return the full file when totalLines <= 2000 and add no window marker', async () => {
const r = await getFile(baseUrl, PROJECT_ID, 'file.txt', null, null);
const textParts = r.content.map((c) => c.text);
const textParts = contentTexts(r.content);
expect(textParts.some((t) => t.startsWith('[od:file-window'))).toBe(false);
const body = textParts[textParts.length - 1];
const body = lastText(textParts);
expect(body.split('\n').length).toBe(500);
expect(body.split('\n')[0]).toBe('line 1');
expect(body.split('\n')[499]).toBe('line 500');
@ -54,14 +75,14 @@ describe('getFile offset/limit slicing', () => {
it('limit caps the slice and stamps a truncation marker with totalLines', async () => {
const r = await getFile(baseUrl, PROJECT_ID, 'file.txt', null, null, 0, 100);
const textParts = r.content.map((c) => c.text);
const textParts = contentTexts(r.content);
const marker = textParts.find((t) => t.startsWith('[od:file-window'));
expect(marker).toBeDefined();
expect(marker).toContain('offset=0');
expect(marker).toContain('returnedLines=100');
expect(marker).toContain('totalLines=500');
expect(marker).toContain('offset=100');
const body = textParts[textParts.length - 1];
const body = lastText(textParts);
expect(body.split('\n').length).toBe(100);
expect(body.split('\n')[0]).toBe('line 1');
expect(body.split('\n')[99]).toBe('line 100');
@ -69,31 +90,31 @@ describe('getFile offset/limit slicing', () => {
it('offset returns a mid-file slice and the marker reflects start', async () => {
const r = await getFile(baseUrl, PROJECT_ID, 'file.txt', null, null, 200, 50);
const textParts = r.content.map((c) => c.text);
const textParts = contentTexts(r.content);
const marker = textParts.find((t) => t.startsWith('[od:file-window'));
expect(marker).toContain('offset=200');
expect(marker).toContain('returnedLines=50');
const body = textParts[textParts.length - 1];
const body = lastText(textParts);
expect(body.split('\n')[0]).toBe('line 201');
expect(body.split('\n')[49]).toBe('line 250');
});
it('offset past EOF returns empty slice but still stamps the marker (no truncation note)', async () => {
const r = await getFile(baseUrl, PROJECT_ID, 'file.txt', null, null, 1000, 50);
const textParts = r.content.map((c) => c.text);
const textParts = contentTexts(r.content);
const marker = textParts.find((t) => t.startsWith('[od:file-window'));
expect(marker).toContain('offset=500');
expect(marker).toContain('returnedLines=0');
expect(marker).toContain('totalLines=500');
expect(marker).not.toContain('call get_file again');
const body = textParts[textParts.length - 1];
const body = lastText(textParts);
expect(body).toBe('');
});
});
describe('getFile binary rejection unchanged', () => {
let server;
let baseUrl;
let server: http.Server;
let baseUrl: string;
beforeAll(async () => {
const r = await startServer(makeDaemonApp('binary-bytes', 'image/png'));
@ -105,8 +126,8 @@ describe('getFile binary rejection unchanged', () => {
it('returns an error result for binary mimes regardless of offset/limit', async () => {
const r = await getFile(baseUrl, PROJECT_ID, 'logo.png', null, null, 0, 100);
expect(r.isError).toBe(true);
const text = r.content.map((c) => c.text).join('\n');
expect('isError' in r && r.isError).toBe(true);
const text = contentTexts(r.content).join('\n');
expect(text).toMatch(/binary content is not yet supported/);
});
});

View file

@ -1,4 +1,3 @@
// @ts-nocheck
import http from 'node:http';
import fs from 'node:fs';
import path from 'node:path';
@ -30,7 +29,26 @@ interface InstallInfoOpts {
dataDir: string;
}
function makeInstallInfoApp({ cliPath, port, env = {}, dataDir }: InstallInfoOpts) {
interface InstallInfoPayload {
command: string;
args: string[];
env: Record<string, string>;
daemonUrl: string | null;
platform: NodeJS.Platform;
cliExists: boolean;
nodeExists: boolean;
buildHint: string | null;
}
interface InstallInfoApp extends express.Express {
_resolveCalls: () => number;
}
async function readInstallInfo(res: Response): Promise<InstallInfoPayload> {
return (await res.json()) as InstallInfoPayload;
}
function makeInstallInfoApp({ cliPath, port, env = {}, dataDir }: InstallInfoOpts): InstallInfoApp {
const app = express();
const TTL_MS = 5000;
@ -80,12 +98,13 @@ function makeInstallInfoApp({ cliPath, port, env = {}, dataDir }: InstallInfoOpt
});
// Test-only escape hatch so assertions can prove the cache cold-paths.
(app as any)._resolveCalls = () => resolveCalls;
return app;
const typedApp = app as InstallInfoApp;
typedApp._resolveCalls = () => resolveCalls;
return typedApp;
}
interface Harness {
app: express.Express;
app: InstallInfoApp;
server: http.Server;
port: number;
baseUrl: string;
@ -118,7 +137,7 @@ describe('GET /api/mcp/install-info', () => {
let dataDir: string;
// Tests share the tmpDir but each top-level case spins its own
// app instance so different env configurations stay isolated.
let nonSidecar: { server: http.Server; port: number; app: express.Express };
let nonSidecar: { server: http.Server; port: number; app: InstallInfoApp };
beforeAll(
() =>
@ -161,7 +180,7 @@ describe('GET /api/mcp/install-info', () => {
const { port } = nonSidecar;
const res = await fetch(`http://127.0.0.1:${port}/api/mcp/install-info`);
expect(res.status).toBe(200);
const body = await res.json();
const body = await readInstallInfo(res);
expect(body.command).toBe(process.execPath);
// Direct `od` launches have no IPC socket; the snippet bakes the
// URL so the spawned `od mcp` reaches the right port without any
@ -180,7 +199,7 @@ describe('GET /api/mcp/install-info', () => {
it('pins OD_DATA_DIR in the env so IDE-spawned MCP processes write to the daemon data dir (issue #848)', async () => {
const { port } = nonSidecar;
const res = await fetch(`http://127.0.0.1:${port}/api/mcp/install-info`);
const body = await res.json();
const body = await readInstallInfo(res);
expect(body.env).toBeDefined();
expect(body.env.OD_DATA_DIR).toBe(dataDir);
});
@ -221,11 +240,11 @@ describe('GET /api/mcp/install-info', () => {
it('caches the payload across rapid calls', async () => {
const { port, app } = nonSidecar;
const before = (app as any)._resolveCalls();
const before = app._resolveCalls();
await fetch(`http://127.0.0.1:${port}/api/mcp/install-info`);
await fetch(`http://127.0.0.1:${port}/api/mcp/install-info`);
await fetch(`http://127.0.0.1:${port}/api/mcp/install-info`);
const after = (app as any)._resolveCalls();
const after = app._resolveCalls();
// 3 rapid calls add at most 1 fresh resolve, not 3.
expect(after - before).toBeLessThanOrEqual(1);
});
@ -241,7 +260,7 @@ describe('GET /api/mcp/install-info', () => {
);
try {
const res = await fetch(`http://127.0.0.1:${port}/api/mcp/install-info`);
const body = await res.json();
const body = await readInstallInfo(res);
expect(body.args).toEqual([cliPath, 'mcp']);
// Default namespace + default IPC base means the spawned `od mcp`
// can derive the right socket without any sidecar env hints. The
@ -263,7 +282,7 @@ describe('GET /api/mcp/install-info', () => {
);
try {
const res = await fetch(`http://127.0.0.1:${port}/api/mcp/install-info`);
const body = await res.json();
const body = await readInstallInfo(res);
expect(body.args).toEqual([cliPath, 'mcp']);
// Without this propagation the MCP client would launch `od mcp`
// with no namespace env, fall back to "default", and miss the
@ -289,7 +308,7 @@ describe('GET /api/mcp/install-info', () => {
);
try {
const res = await fetch(`http://127.0.0.1:${port}/api/mcp/install-info`);
const body = await res.json();
const body = await readInstallInfo(res);
expect(body.env).toEqual({
OD_DATA_DIR: dataDir,
[SIDECAR_ENV.NAMESPACE]: 'foo',

View file

@ -1,5 +1,5 @@
// @ts-nocheck
import http from 'node:http';
import type { AddressInfo } from 'node:net';
import express from 'express';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { resolveProjectId, withActiveEcho } from '../src/mcp.js';
@ -12,17 +12,17 @@ const PROJECTS = [
];
describe('resolveProjectId', () => {
let server;
let baseUrl;
let server: http.Server;
let baseUrl: string;
beforeAll(
() =>
new Promise((resolve) => {
new Promise<void>((resolve) => {
const app = express();
app.get('/api/projects', (_req, res) => res.json({ projects: PROJECTS }));
const tmp = http.createServer();
tmp.listen(0, '127.0.0.1', () => {
const { port } = tmp.address();
const { port } = tmp.address() as AddressInfo;
baseUrl = `http://127.0.0.1:${port}`;
tmp.close(() => {
server = app.listen(port, '127.0.0.1', () => resolve());

View file

@ -1,6 +1,7 @@
// @ts-nocheck
import http from 'node:http';
import type { AddressInfo } from 'node:net';
import express from 'express';
import type { NextFunction, Request, Response } from 'express';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import {
allowedBrowserPorts,
@ -9,10 +10,41 @@ import {
isLocalSameOrigin,
} from '../src/origin-validation.js';
function createOriginMiddleware(resolvedPort, host = '127.0.0.1') {
type TestRequestOptions = {
origin?: string;
headers?: http.OutgoingHttpHeaders;
};
type TestResponse = {
status: number | undefined;
body: string;
headers: http.IncomingHttpHeaders;
};
function getListeningPort(server: http.Server): number {
const address = server.address();
if (address == null || typeof address === 'string') {
throw new Error('Expected HTTP server to listen on a TCP port');
}
return (address as AddressInfo).port;
}
function closeServer(server: http.Server): Promise<void> {
return new Promise((resolve, reject) => {
server.close((error) => {
if (error != null) {
reject(error);
return;
}
resolve();
});
});
}
function createOriginMiddleware(resolvedPort: number, host = '127.0.0.1') {
const _NULL_ORIGIN_SAFE_GET_RE =
/^\/projects\/[^/]+\/raw\/|^\/codex-pets\/[^/]+\/spritesheet$/;
return (req, res, next) => {
return (req: Request, res: Response, next: NextFunction) => {
const origin = req.headers.origin;
if (origin == null || origin === '') return next();
if (origin === 'null') {
@ -35,7 +67,7 @@ function createOriginMiddleware(resolvedPort, host = '127.0.0.1') {
};
}
function makeTestApp(port, host = '127.0.0.1') {
function makeTestApp(port: number, host = '127.0.0.1') {
const app = express();
app.use(express.json());
app.use('/api', createOriginMiddleware(port, host));
@ -66,9 +98,14 @@ function makeTestApp(port, host = '127.0.0.1') {
return app;
}
function request(port, method, path, { origin, headers = {} } = {}) {
return new Promise((resolve) => {
const opts = {
function request(
port: number,
method: string,
path: string,
{ origin, headers = {} }: TestRequestOptions = {},
): Promise<TestResponse> {
return new Promise((resolve, reject) => {
const opts: http.RequestOptions = {
hostname: '127.0.0.1',
port,
path,
@ -80,37 +117,36 @@ function request(port, method, path, { origin, headers = {} } = {}) {
};
const req = http.request(opts, (res) => {
let body = '';
res.on('data', (chunk) => (body += chunk));
res.setEncoding('utf8');
res.on('data', (chunk: string) => (body += chunk));
res.on('end', () => resolve({ status: res.statusCode, body, headers: res.headers }));
});
req.on('error', reject);
req.end();
});
}
describe('daemon origin validation middleware', () => {
let server;
let port;
let server: http.Server;
let port: number;
beforeAll(
() =>
new Promise((resolve) => {
new Promise<void>((resolve) => {
// Start on port 0 to get a dynamic port, then rebuild with real port
const tempApp = makeTestApp(0);
const tempServer = tempApp.listen(0, '127.0.0.1', () => {
port = tempServer.address().port;
port = getListeningPort(tempServer);
tempServer.close(() => {
const realApp = makeTestApp(port);
server = realApp.listen(port, '127.0.0.1', () => resolve());
server = realApp.listen(port, '127.0.0.1', resolve);
});
});
}),
);
afterAll(
() =>
new Promise((resolve) => {
server.close(() => resolve());
}),
() => closeServer(server),
);
// --- Non-browser clients (no Origin) ---
@ -358,25 +394,22 @@ describe('daemon origin validation middleware', () => {
});
describe('origin validation: fail-closed before port resolution', () => {
let server;
let port;
let server: http.Server;
let port: number;
beforeAll(
() =>
new Promise((resolve) => {
new Promise<void>((resolve) => {
const app = makeTestApp(0); // port=0 → not resolved
server = app.listen(0, '127.0.0.1', () => {
port = server.address().port;
port = getListeningPort(server);
resolve();
});
}),
);
afterAll(
() =>
new Promise((resolve) => {
server.close(() => resolve());
}),
() => closeServer(server),
);
it('blocks browser origins when port is not resolved (fail-closed)', async () => {
@ -393,30 +426,27 @@ describe('origin validation: fail-closed before port resolution', () => {
});
describe('origin validation: non-loopback bind host', () => {
let server;
let port;
let server: http.Server;
let port: number;
const nonLoopbackHost = '100.64.1.2'; // Tailscale-like address
beforeAll(
() =>
new Promise((resolve) => {
new Promise<void>((resolve) => {
// Start on port 0 to get a dynamic port, then rebuild with real port
const tempApp = makeTestApp(0, nonLoopbackHost);
const tempServer = tempApp.listen(0, '127.0.0.1', () => {
port = tempServer.address().port;
port = getListeningPort(tempServer);
tempServer.close(() => {
const realApp = makeTestApp(port, nonLoopbackHost);
server = realApp.listen(port, '127.0.0.1', () => resolve());
server = realApp.listen(port, '127.0.0.1', resolve);
});
});
}),
);
afterAll(
() =>
new Promise((resolve) => {
server.close(() => resolve());
}),
() => closeServer(server),
);
it('allows browser requests from the non-loopback bind host', async () => {

View file

@ -1,10 +1,11 @@
// @ts-nocheck
import { test } from 'vitest';
import assert from 'node:assert/strict';
import path from 'node:path';
import { parsePiModels, mapPiRpcEvent, attachPiRpcSession } from '../src/pi-rpc.js';
import { EventEmitter } from 'node:events';
import { PassThrough } from 'node:stream';
import type { ChildProcess } from 'node:child_process';
import type { Writable } from 'node:stream';
// ─── parsePiModels ─────────────────────────────────────────────────────────
@ -19,8 +20,8 @@ test('parsePiModels parses TSV table with default option prepended', () => {
assert.ok(result);
assert.equal(result.length, 3);
assert.deepEqual(result[0], { id: 'default', label: 'Default (CLI config)' });
assert.equal(result[1].id, 'anthropic/claude-sonnet-4-5');
assert.equal(result[2].id, 'openai/gpt-5');
assert.equal(result[1]?.id, 'anthropic/claude-sonnet-4-5');
assert.equal(result[2]?.id, 'openai/gpt-5');
});
test('parsePiModels deduplicates identical provider/model pairs', () => {
@ -33,7 +34,7 @@ test('parsePiModels deduplicates identical provider/model pairs', () => {
assert.ok(result);
assert.equal(result.length, 2); // default + 1 unique
assert.equal(result[1].id, 'openrouter/claude-sonnet-4-5');
assert.equal(result[1]?.id, 'openrouter/claude-sonnet-4-5');
});
test('parsePiModels returns null for empty input', () => {
@ -58,7 +59,7 @@ test('parsePiModels skips lines with fewer than 2 columns', () => {
assert.ok(result);
assert.equal(result.length, 2); // default + 1 valid
assert.equal(result[1].id, 'anthropic/claude-sonnet-4-5');
assert.equal(result[1]?.id, 'anthropic/claude-sonnet-4-5');
});
test('parsePiModels handles comment lines', () => {
@ -71,7 +72,7 @@ test('parsePiModels handles comment lines', () => {
assert.ok(result);
assert.equal(result.length, 2);
assert.equal(result[1].id, 'anthropic/claude-sonnet-4-5');
assert.equal(result[1]?.id, 'anthropic/claude-sonnet-4-5');
});
test('parsePiModels handles large model lists', () => {
@ -84,7 +85,7 @@ test('parsePiModels handles large model lists', () => {
const result = parsePiModels(input);
assert.ok(result);
assert.equal(result[0].id, 'default');
assert.equal(result[0]?.id, 'default');
assert.equal(result.length, 601); // default + 600
});
@ -98,8 +99,8 @@ test('parsePiModels skips duplicate default id', () => {
assert.ok(result);
assert.equal(result.length, 3); // synthetic default + default/some-model + anthropic/claude-sonnet-4-5
assert.equal(result[0].id, 'default');
assert.equal(result[1].id, 'default/some-model');
assert.equal(result[0]?.id, 'default');
assert.equal(result[1]?.id, 'default/some-model');
});
// ─── RPC event translation (mapPiRpcEvent) ────────────────────────────────
@ -109,19 +110,57 @@ test('parsePiModels skips duplicate default id', () => {
import { createJsonLineStream } from '../src/acp.js';
function simulateRpcSession(rpcLines, options = {}) {
const events = [];
const send = (_channel, payload) => {
type JsonRecord = Record<string, unknown>;
type TestAgentEvent = JsonRecord & { type?: string; label?: string; message?: string; delta?: string };
type TestSentEvent = TestAgentEvent & { channel?: string };
type MockWritable = Pick<Writable, 'write'>;
type MockChildProcess = EventEmitter & {
stdin: PassThrough;
stdout: PassThrough;
stderr: PassThrough;
killed: boolean;
kill: (signal?: NodeJS.Signals | number) => boolean;
};
function asRecord(value: unknown): JsonRecord {
assert.ok(value && typeof value === 'object');
return value as JsonRecord;
}
function parseJsonRecord(line: string): JsonRecord {
return asRecord(JSON.parse(line) as unknown);
}
function eventAt(events: TestAgentEvent[], index: number): TestAgentEvent {
const event = events[index];
assert.ok(event, `expected event at index ${index}`);
return event;
}
function usageOf(event: TestAgentEvent): JsonRecord {
return asRecord(event.usage);
}
function imagesOf(parsed: JsonRecord): JsonRecord[] {
assert.ok(Array.isArray(parsed.images));
return parsed.images.map((image) => asRecord(image));
}
function simulateRpcSession(rpcLines: JsonRecord[]): TestAgentEvent[] {
const events: TestAgentEvent[] = [];
const send = (_channel: string, payload: JsonRecord) => {
events.push(payload);
};
const ctx = { runStartedAt: Date.now(), sentFirstToken: { value: false } };
const parser = createJsonLineStream((raw) => {
const parser = createJsonLineStream((raw: unknown) => {
if (!raw || typeof raw !== 'object') return;
const event = raw as JsonRecord;
// Skip non-agent events that mapPiRpcEvent doesn't handle.
if (raw.type === 'extension_ui_request') return;
if (raw.type === 'response') return;
if (event.type === 'extension_ui_request') return;
if (event.type === 'response') return;
mapPiRpcEvent(raw, send, ctx);
mapPiRpcEvent(event, send, ctx);
});
const input = rpcLines.map((l) => JSON.stringify(l)).join('\n') + '\n';
@ -147,7 +186,7 @@ test('pi RPC: text streaming from message_update events', () => {
assert.deepEqual(events, [
{ type: 'status', label: 'working' },
{ type: 'status', label: 'thinking' },
{ type: 'status', label: 'streaming', ttftMs: events[2].ttftMs },
{ type: 'status', label: 'streaming', ttftMs: eventAt(events, 2).ttftMs },
{ type: 'text_delta', delta: 'Hello ' },
{ type: 'text_delta', delta: 'world' },
]);
@ -194,8 +233,8 @@ test('pi RPC: usage extracted from turn_end', () => {
]);
assert.equal(events.length, 3);
assert.equal(events[2].type, 'usage');
assert.deepEqual(events[2].usage, {
assert.equal(eventAt(events, 2).type, 'usage');
assert.deepEqual(eventAt(events, 2).usage, {
input_tokens: 100,
output_tokens: 50,
cached_read_tokens: 20,
@ -234,7 +273,7 @@ test('pi RPC: tool error results flagged correctly', () => {
]);
assert.equal(events.length, 1);
assert.equal(events[0].isError, true);
assert.equal(eventAt(events, 0).isError, true);
});
test('pi RPC: compaction and retry status events', () => {
@ -258,8 +297,8 @@ test('pi RPC: extension UI fire-and-forget events are silently consumed', () =>
// Only agent_start should produce an event; the UI requests are consumed.
assert.equal(events.length, 1);
assert.equal(events[0].type, 'status');
assert.equal(events[0].label, 'working');
assert.equal(eventAt(events, 0).type, 'status');
assert.equal(eventAt(events, 0).label, 'working');
});
test('pi RPC: response events are silently consumed', () => {
@ -269,7 +308,7 @@ test('pi RPC: response events are silently consumed', () => {
]);
assert.equal(events.length, 1);
assert.equal(events[0].label, 'working');
assert.equal(eventAt(events, 0).label, 'working');
});
test('pi RPC: full multi-turn session with tools and usage', () => {
@ -317,8 +356,8 @@ test('pi RPC: full multi-turn session with tools and usage', () => {
// Usage from both turns
const usageEvents = events.filter((e) => e.type === 'usage');
assert.equal(usageEvents.length, 2);
assert.equal(usageEvents[0].usage.input_tokens, 200);
assert.equal(usageEvents[1].usage.cached_read_tokens, 100);
assert.equal(usageOf(eventAt(usageEvents, 0)).input_tokens, 200);
assert.equal(usageOf(eventAt(usageEvents, 1)).cached_read_tokens, 100);
});
test('pi RPC: tool_use arrives before tool_result in event order', () => {
@ -342,16 +381,17 @@ test('pi RPC: tool_use arrives before tool_result in event order', () => {
test('pi RPC: sendCommand writes well-formed pi command JSON', async () => {
// We test the wire format by capturing what gets written to a mock writable.
const written = [];
const written: string[] = [];
const mockWritable = {
write(data) {
write(data: string) {
written.push(data);
return true;
},
};
// Inline the sendCommand logic (same as in pi-rpc.js)
let nextId = 1;
function sendCommand(writable, type, params = {}) {
function sendCommand(writable: MockWritable, type: string, params: JsonRecord = {}) {
const id = nextId++;
writable.write(`${JSON.stringify({ id, type, ...params })}\n`);
return id;
@ -361,18 +401,18 @@ test('pi RPC: sendCommand writes well-formed pi command JSON', async () => {
assert.equal(id, 1);
assert.equal(written.length, 1);
const parsed = JSON.parse(written[0].trim());
const parsed = parseJsonRecord(written[0] ?? '');
assert.equal(parsed.type, 'prompt');
assert.equal(parsed.id, 1);
assert.equal(parsed.message, 'hello');
});
test('pi RPC: sendCommand increments ids across calls', () => {
const written = [];
const mockWritable = { write(data) { written.push(data); } };
const written: string[] = [];
const mockWritable = { write(data: string) { written.push(data); return true; } };
let nextId = 1;
function sendCommand(writable, type, params = {}) {
function sendCommand(writable: MockWritable, type: string, params: JsonRecord = {}) {
const id = nextId++;
writable.write(`${JSON.stringify({ id, type, ...params })}\n`);
return id;
@ -383,8 +423,8 @@ test('pi RPC: sendCommand increments ids across calls', () => {
assert.equal(id1, 1);
assert.equal(id2, 2);
const p1 = JSON.parse(written[0].trim());
const p2 = JSON.parse(written[1].trim());
const p1 = parseJsonRecord(written[0] ?? '');
const p2 = parseJsonRecord(written[1] ?? '');
assert.equal(p1.type, 'prompt');
assert.equal(p2.type, 'steer');
});
@ -392,21 +432,21 @@ test('pi RPC: sendCommand increments ids across calls', () => {
test('pi RPC: concurrent sessions get independent id sequences', () => {
// Each session has its own nextRpcId counter, so two sessions
// spawned at the same time get non-colliding ids.
const written1 = [];
const written2 = [];
const mock1 = { write(data) { written1.push(data); } };
const mock2 = { write(data) { written2.push(data); } };
const written1: string[] = [];
const written2: string[] = [];
const mock1 = { write(data: string) { written1.push(data); return true; } };
const mock2 = { write(data: string) { written2.push(data); return true; } };
// Session 1
let nextId1 = 1;
function send1(w, type, params = {}) {
function send1(w: MockWritable, type: string, params: JsonRecord = {}) {
const id = nextId1++;
w.write(`${JSON.stringify({ id, type, ...params })}\n`);
return id;
}
// Session 2
let nextId2 = 1;
function send2(w, type, params = {}) {
function send2(w: MockWritable, type: string, params: JsonRecord = {}) {
const id = nextId2++;
w.write(`${JSON.stringify({ id, type, ...params })}\n`);
return id;
@ -417,8 +457,8 @@ test('pi RPC: concurrent sessions get independent id sequences', () => {
assert.equal(id1, 1);
assert.equal(id2, 1); // independent counter
const p1 = JSON.parse(written1[0].trim());
const p2 = JSON.parse(written2[0].trim());
const p1 = parseJsonRecord(written1[0] ?? '');
const p2 = parseJsonRecord(written2[0] ?? '');
assert.equal(p1.id, 1);
assert.equal(p2.id, 1);
});
@ -448,7 +488,7 @@ test('pi RPC: no duplicate usage when both message_end and turn_end carry usage'
const usageEvents = events.filter((e) => e.type === 'usage');
assert.equal(usageEvents.length, 1, 'should emit exactly one usage event per turn');
assert.equal(usageEvents[0].usage.input_tokens, 100);
assert.equal(usageOf(eventAt(usageEvents, 0)).input_tokens, 100);
});
// ─── attachPiRpcSession integration tests ──────────────────────────────────
@ -457,27 +497,28 @@ test('pi RPC: no duplicate usage when both message_end and turn_end carry usage'
// so regressions in the actual function (wrong events, missing model
// normalization, abort not writing to stdin, etc.) are caught.
function createMockChild() {
const child = new EventEmitter();
function createMockChild(): MockChildProcess {
const child = new EventEmitter() as MockChildProcess;
child.stdin = new PassThrough();
child.stdout = new PassThrough();
child.stderr = new PassThrough();
child.killed = false;
child.kill = (signal) => {
child.kill = (signal?: NodeJS.Signals | number) => {
child.killed = true;
child.emit('close', null, signal);
return true;
};
return child;
}
function createSession(childOpts = {}) {
const events = [];
const send = (channel, payload) => events.push({ channel, ...payload });
function createSession(childOpts: { model?: string | null } = {}) {
const events: TestSentEvent[] = [];
const send = (channel: string, payload: JsonRecord) => events.push({ channel, ...payload });
const model = childOpts.model ?? null;
const child = createMockChild();
const session = attachPiRpcSession({
child,
child: child as unknown as ChildProcess,
prompt: 'test prompt',
cwd: '/tmp',
model,
@ -487,12 +528,12 @@ function createSession(childOpts = {}) {
return { child, session, events, send };
}
function feedStdoutLines(child, lines) {
function feedStdoutLines(child: MockChildProcess, lines: JsonRecord[]) {
const input = lines.map((l) => JSON.stringify(l)).join('\n') + '\n';
child.stdout.write(input);
}
function closeStdout(child) {
function closeStdout(child: MockChildProcess) {
child.stdout.end();
child.stdin.end();
}
@ -521,18 +562,18 @@ test('attachPiRpcSession sends prompt command on stdin', () => {
const { child } = createSession();
// Read what was written to stdin — the first line should be a prompt command.
const chunks = [];
child.stdin.on('data', (chunk) => chunks.push(chunk.toString()));
const chunks: string[] = [];
child.stdin.on('data', (chunk: Buffer) => chunks.push(chunk.toString()));
// stdin already received the prompt write; PassThrough buffers it.
const buffered = child.stdin.read();
if (buffered) chunks.push(buffered.toString());
const lines = chunks.join('').trim().split('\n');
const promptLine = lines.find((l) => {
try { return JSON.parse(l).type === 'prompt'; } catch { return false; }
try { return parseJsonRecord(l).type === 'prompt'; } catch { return false; }
});
assert.ok(promptLine, 'should send a prompt command on stdin');
const parsed = JSON.parse(promptLine);
const parsed = parseJsonRecord(promptLine);
assert.equal(parsed.type, 'prompt');
assert.equal(parsed.message, 'test prompt');
});
@ -543,8 +584,8 @@ test('attachPiRpcSession abort() writes well-formed abort command to stdin', ()
// Drain any buffered stdin data (the prompt command) before abort.
child.stdin.read();
const chunks = [];
child.stdin.on('data', (chunk) => chunks.push(chunk.toString()));
const chunks: string[] = [];
child.stdin.on('data', (chunk: Buffer) => chunks.push(chunk.toString()));
session.abort();
@ -554,10 +595,10 @@ test('attachPiRpcSession abort() writes well-formed abort command to stdin', ()
const lines = chunks.join('').trim().split('\n');
const abortLine = lines.find((l) => {
try { return JSON.parse(l).type === 'abort'; } catch { return false; }
try { return parseJsonRecord(l).type === 'abort'; } catch { return false; }
});
assert.ok(abortLine, 'should send an abort command on stdin');
const parsed = JSON.parse(abortLine);
const parsed = parseJsonRecord(abortLine);
assert.equal(parsed.type, 'abort');
assert.equal(typeof parsed.id, 'number');
});
@ -572,8 +613,8 @@ test('attachPiRpcSession abort() is idempotent and no-op after stdin close', ()
child.stdin.end();
child.stdin.emit('close');
const chunks = [];
child.stdin.on('data', (chunk) => chunks.push(chunk.toString()));
const chunks: string[] = [];
child.stdin.on('data', (chunk: Buffer) => chunks.push(chunk.toString()));
// abort() should be a no-op because finished is already true or stdin is closed.
session.abort();
@ -591,8 +632,8 @@ test('pi RPC: extension_error maps to error event', () => {
]);
assert.equal(events.length, 1);
assert.equal(events[0].type, 'error');
assert.equal(events[0].message, 'Something broke');
assert.equal(eventAt(events, 0).type, 'error');
assert.equal(eventAt(events, 0).message, 'Something broke');
});
test('pi RPC: extension_error with non-string error uses fallback', () => {
@ -601,8 +642,8 @@ test('pi RPC: extension_error with non-string error uses fallback', () => {
]);
assert.equal(events.length, 1);
assert.equal(events[0].type, 'error');
assert.equal(events[0].message, 'Extension error');
assert.equal(eventAt(events, 0).type, 'error');
assert.equal(eventAt(events, 0).message, 'Extension error');
});
test('pi RPC: extension_error with missing error uses fallback', () => {
@ -611,8 +652,8 @@ test('pi RPC: extension_error with missing error uses fallback', () => {
]);
assert.equal(events.length, 1);
assert.equal(events[0].type, 'error');
assert.equal(events[0].message, 'Extension error');
assert.equal(eventAt(events, 0).type, 'error');
assert.equal(eventAt(events, 0).message, 'Extension error');
});
// ─── message_update error delta handling ────────────────────────────────────
@ -626,8 +667,8 @@ test('pi RPC: message_update with error delta maps to error event', () => {
]);
assert.equal(events.length, 1);
assert.equal(events[0].type, 'error');
assert.equal(events[0].message, 'aborted');
assert.equal(eventAt(events, 0).type, 'error');
assert.equal(eventAt(events, 0).message, 'aborted');
});
test('pi RPC: message_update error delta falls back to delta text', () => {
@ -639,8 +680,8 @@ test('pi RPC: message_update error delta falls back to delta text', () => {
]);
assert.equal(events.length, 1);
assert.equal(events[0].type, 'error');
assert.equal(events[0].message, 'Connection reset');
assert.equal(eventAt(events, 0).type, 'error');
assert.equal(eventAt(events, 0).message, 'Connection reset');
});
test('pi RPC: message_update error delta with no reason or delta uses fallback', () => {
@ -652,8 +693,8 @@ test('pi RPC: message_update error delta with no reason or delta uses fallback',
]);
assert.equal(events.length, 1);
assert.equal(events[0].type, 'error');
assert.equal(events[0].message, 'Agent error');
assert.equal(eventAt(events, 0).type, 'error');
assert.equal(eventAt(events, 0).message, 'Agent error');
});
test('pi RPC: message_update error after partial output still emits error', () => {
@ -691,10 +732,10 @@ test('pi RPC: auto_retry_end with success=false maps to error event', () => {
// auto_retry_start → status:retrying, auto_retry_end → error
assert.equal(events.length, 2);
assert.equal(events[0].type, 'status');
assert.equal(events[0].label, 'retrying');
assert.equal(events[1].type, 'error');
assert.equal(events[1].message, '529 overloaded_error: Overloaded');
assert.equal(eventAt(events, 0).type, 'status');
assert.equal(eventAt(events, 0).label, 'retrying');
assert.equal(eventAt(events, 1).type, 'error');
assert.equal(eventAt(events, 1).message, '529 overloaded_error: Overloaded');
});
test('pi RPC: auto_retry_end with success=true does not emit error', () => {
@ -704,8 +745,8 @@ test('pi RPC: auto_retry_end with success=true does not emit error', () => {
]);
assert.equal(events.length, 1);
assert.equal(events[0].type, 'status');
assert.equal(events[0].label, 'retrying');
assert.equal(eventAt(events, 0).type, 'status');
assert.equal(eventAt(events, 0).label, 'retrying');
});
test('pi RPC: auto_retry_end failure with missing finalError uses fallback', () => {
@ -714,8 +755,8 @@ test('pi RPC: auto_retry_end failure with missing finalError uses fallback', ()
]);
assert.equal(events.length, 1);
assert.equal(events[0].type, 'error');
assert.equal(events[0].message, 'Auto-retry exhausted');
assert.equal(eventAt(events, 0).type, 'error');
assert.equal(eventAt(events, 0).message, 'Auto-retry exhausted');
});
// ─── imagePaths forwarding in attachPiRpcSession ─────────────────────────────
@ -731,12 +772,12 @@ test('attachPiRpcSession sends prompt with images when imagePaths provided', asy
);
try {
const events2 = [];
const send2 = (channel, payload) => events2.push({ channel, ...payload });
const events2: TestSentEvent[] = [];
const send2 = (channel: string, payload: JsonRecord) => events2.push({ channel, ...payload });
const child2 = createMockChild();
attachPiRpcSession({
child: child2,
child: child2 as unknown as ChildProcess,
prompt: 'describe this image',
cwd: '/tmp',
model: null,
@ -745,34 +786,34 @@ test('attachPiRpcSession sends prompt with images when imagePaths provided', asy
});
// Read the stdin data to find the prompt command.
const chunks = [];
child2.stdin.on('data', (chunk) => chunks.push(chunk.toString()));
const chunks: string[] = [];
child2.stdin.on('data', (chunk: Buffer) => chunks.push(chunk.toString()));
const buffered = child2.stdin.read();
if (buffered) chunks.push(buffered.toString());
const lines = chunks.join('').trim().split('\n');
const promptLine = lines.find((l) => {
try { return JSON.parse(l).type === 'prompt'; } catch { return false; }
try { return parseJsonRecord(l).type === 'prompt'; } catch { return false; }
});
assert.ok(promptLine, 'should send a prompt command');
const parsed = JSON.parse(promptLine);
assert.ok(parsed.images, 'prompt should include images array');
assert.equal(parsed.images.length, 1);
assert.equal(parsed.images[0].type, 'image');
assert.equal(parsed.images[0].mimeType, 'image/png');
assert.ok(typeof parsed.images[0].data === 'string' && parsed.images[0].data.length > 0);
const parsed = parseJsonRecord(promptLine);
const images = imagesOf(parsed);
assert.equal(images.length, 1);
assert.equal(images[0]?.type, 'image');
assert.equal(images[0]?.mimeType, 'image/png');
assert.ok(typeof images[0]?.data === 'string' && images[0].data.length > 0);
} finally {
await import('node:fs/promises').then((fsp) => fsp.unlink(tmpFile).catch(() => {}));
}
});
test('attachPiRpcSession sends prompt without images when imagePaths is empty', () => {
const events = [];
const send = (channel, payload) => events.push({ channel, ...payload });
const events: TestSentEvent[] = [];
const send = (channel: string, payload: JsonRecord) => events.push({ channel, ...payload });
const child = createMockChild();
attachPiRpcSession({
child,
child: child as unknown as ChildProcess,
prompt: 'hello',
cwd: '/tmp',
model: null,
@ -780,27 +821,27 @@ test('attachPiRpcSession sends prompt without images when imagePaths is empty',
imagePaths: [],
});
const chunks = [];
child.stdin.on('data', (chunk) => chunks.push(chunk.toString()));
const chunks: string[] = [];
child.stdin.on('data', (chunk: Buffer) => chunks.push(chunk.toString()));
const buffered = child.stdin.read();
if (buffered) chunks.push(buffered.toString());
const lines = chunks.join('').trim().split('\n');
const promptLine = lines.find((l) => {
try { return JSON.parse(l).type === 'prompt'; } catch { return false; }
try { return parseJsonRecord(l).type === 'prompt'; } catch { return false; }
});
assert.ok(promptLine, 'should send a prompt command');
const parsed = JSON.parse(promptLine);
const parsed = parseJsonRecord(promptLine);
assert.equal(parsed.images, undefined, 'prompt should not include images when none provided');
});
test('attachPiRpcSession skips unreadable image paths gracefully', () => {
const events = [];
const send = (channel, payload) => events.push({ channel, ...payload });
const events: TestSentEvent[] = [];
const send = (channel: string, payload: JsonRecord) => events.push({ channel, ...payload });
const child = createMockChild();
attachPiRpcSession({
child,
child: child as unknown as ChildProcess,
prompt: 'check this',
cwd: '/tmp',
model: null,
@ -808,17 +849,17 @@ test('attachPiRpcSession skips unreadable image paths gracefully', () => {
imagePaths: ['/nonexistent/path/fake-image.png'],
});
const chunks = [];
child.stdin.on('data', (chunk) => chunks.push(chunk.toString()));
const chunks: string[] = [];
child.stdin.on('data', (chunk: Buffer) => chunks.push(chunk.toString()));
const buffered = child.stdin.read();
if (buffered) chunks.push(buffered.toString());
const lines = chunks.join('').trim().split('\n');
const promptLine = lines.find((l) => {
try { return JSON.parse(l).type === 'prompt'; } catch { return false; }
try { return parseJsonRecord(l).type === 'prompt'; } catch { return false; }
});
assert.ok(promptLine, 'should send a prompt command');
const parsed = JSON.parse(promptLine);
const parsed = parseJsonRecord(promptLine);
assert.equal(parsed.images, undefined, 'prompt should not include images for unreadable paths');
});
@ -828,12 +869,12 @@ test('attachPiRpcSession rejects non-file image paths (directories)', async () =
const dirPath = path.join(tmpDir, `pi-rpc-test-dir-${Date.now()}`);
await fsp.mkdir(dirPath);
try {
const events = [];
const send = (channel, payload) => events.push({ channel, ...payload });
const events: TestSentEvent[] = [];
const send = (channel: string, payload: JsonRecord) => events.push({ channel, ...payload });
const child = createMockChild();
attachPiRpcSession({
child,
child: child as unknown as ChildProcess,
prompt: 'check this dir',
cwd: '/tmp',
model: null,
@ -841,17 +882,17 @@ test('attachPiRpcSession rejects non-file image paths (directories)', async () =
imagePaths: [dirPath],
});
const chunks = [];
child.stdin.on('data', (chunk) => chunks.push(chunk.toString()));
const chunks: string[] = [];
child.stdin.on('data', (chunk: Buffer) => chunks.push(chunk.toString()));
const buffered = child.stdin.read();
if (buffered) chunks.push(buffered.toString());
const lines = chunks.join('').trim().split('\n');
const promptLine = lines.find((l) => {
try { return JSON.parse(l).type === 'prompt'; } catch { return false; }
try { return parseJsonRecord(l).type === 'prompt'; } catch { return false; }
});
assert.ok(promptLine);
const parsed = JSON.parse(promptLine);
const parsed = parseJsonRecord(promptLine);
assert.equal(parsed.images, undefined, 'directories should not be forwarded as images');
} finally {
await fsp.rmdir(dirPath);
@ -864,12 +905,12 @@ test('attachPiRpcSession rejects disallowed image extensions', async () => {
const tmpFile = path.join(tmpDir, `pi-rpc-test-${Date.now()}.txt`);
await fsp.writeFile(tmpFile, 'not an image');
try {
const events = [];
const send = (channel, payload) => events.push({ channel, ...payload });
const events: TestSentEvent[] = [];
const send = (channel: string, payload: JsonRecord) => events.push({ channel, ...payload });
const child = createMockChild();
attachPiRpcSession({
child,
child: child as unknown as ChildProcess,
prompt: 'what is this',
cwd: '/tmp',
model: null,
@ -877,17 +918,17 @@ test('attachPiRpcSession rejects disallowed image extensions', async () => {
imagePaths: [tmpFile],
});
const chunks = [];
child.stdin.on('data', (chunk) => chunks.push(chunk.toString()));
const chunks: string[] = [];
child.stdin.on('data', (chunk: Buffer) => chunks.push(chunk.toString()));
const buffered = child.stdin.read();
if (buffered) chunks.push(buffered.toString());
const lines = chunks.join('').trim().split('\n');
const promptLine = lines.find((l) => {
try { return JSON.parse(l).type === 'prompt'; } catch { return false; }
try { return parseJsonRecord(l).type === 'prompt'; } catch { return false; }
});
assert.ok(promptLine);
const parsed = JSON.parse(promptLine);
const parsed = parseJsonRecord(promptLine);
assert.equal(parsed.images, undefined, '.txt files should not be forwarded as images');
} finally {
await fsp.unlink(tmpFile);
@ -908,12 +949,12 @@ test('attachPiRpcSession rejects symlink escape outside uploadRoot', async () =>
const symlinkPath = path.join(uploadRoot, 'escape.jpg');
await fsp.symlink(outsideFile, symlinkPath);
try {
const events = [];
const send = (channel, payload) => events.push({ channel, ...payload });
const events: TestSentEvent[] = [];
const send = (channel: string, payload: JsonRecord) => events.push({ channel, ...payload });
const child = createMockChild();
attachPiRpcSession({
child,
child: child as unknown as ChildProcess,
prompt: 'check this',
cwd: '/tmp',
model: null,
@ -922,17 +963,17 @@ test('attachPiRpcSession rejects symlink escape outside uploadRoot', async () =>
uploadRoot,
});
const chunks = [];
child.stdin.on('data', (chunk) => chunks.push(chunk.toString()));
const chunks: string[] = [];
child.stdin.on('data', (chunk: Buffer) => chunks.push(chunk.toString()));
const buffered = child.stdin.read();
if (buffered) chunks.push(buffered.toString());
const lines = chunks.join('').trim().split('\n');
const promptLine = lines.find((l) => {
try { return JSON.parse(l).type === 'prompt'; } catch { return false; }
try { return parseJsonRecord(l).type === 'prompt'; } catch { return false; }
});
assert.ok(promptLine);
const parsed = JSON.parse(promptLine);
const parsed = parseJsonRecord(promptLine);
assert.equal(parsed.images, undefined, 'symlinks resolving outside uploadRoot should not be forwarded as images');
} finally {
await fsp.unlink(symlinkPath);
@ -955,12 +996,12 @@ test('attachPiRpcSession allows symlink inside uploadRoot', async () => {
const symlinkPath = path.join(uploadRoot, 'link.png');
await fsp.symlink(realFile, symlinkPath);
try {
const events = [];
const send = (channel, payload) => events.push({ channel, ...payload });
const events: TestSentEvent[] = [];
const send = (channel: string, payload: JsonRecord) => events.push({ channel, ...payload });
const child = createMockChild();
attachPiRpcSession({
child,
child: child as unknown as ChildProcess,
prompt: 'check this',
cwd: '/tmp',
model: null,
@ -969,17 +1010,17 @@ test('attachPiRpcSession allows symlink inside uploadRoot', async () => {
uploadRoot,
});
const chunks = [];
child.stdin.on('data', (chunk) => chunks.push(chunk.toString()));
const chunks: string[] = [];
child.stdin.on('data', (chunk: Buffer) => chunks.push(chunk.toString()));
const buffered = child.stdin.read();
if (buffered) chunks.push(buffered.toString());
const lines = chunks.join('').trim().split('\n');
const promptLine = lines.find((l) => {
try { return JSON.parse(l).type === 'prompt'; } catch { return false; }
try { return parseJsonRecord(l).type === 'prompt'; } catch { return false; }
});
assert.ok(promptLine);
const parsed = JSON.parse(promptLine);
const parsed = parseJsonRecord(promptLine);
assert.ok(Array.isArray(parsed.images), 'symlink inside uploadRoot should be forwarded as image');
assert.equal(parsed.images.length, 1);
assert.equal(parsed.images[0].type, 'image');

View file

@ -105,6 +105,7 @@ describe('mimeFor', () => {
expect(mimeFor('a.jsx')).toBe('text/javascript; charset=utf-8');
expect(mimeFor('a.tsx')).toBe('text/javascript; charset=utf-8');
expect(mimeFor('a.ts')).toBe('text/typescript; charset=utf-8');
expect(mimeFor('a.py')).toBe('text/x-python; charset=utf-8');
expect(mimeFor('a.json')).toBe('application/json; charset=utf-8');
expect(mimeFor('a.md')).toBe('text/markdown; charset=utf-8');
expect(mimeFor('a.txt')).toBe('text/plain; charset=utf-8');
@ -151,6 +152,7 @@ describe('mimeFor', () => {
it('is case-insensitive on the extension', () => {
expect(mimeFor('IMG.PNG')).toBe('image/png');
expect(mimeFor('PAGE.HTML')).toBe('text/html; charset=utf-8');
expect(mimeFor('SCRIPT.PY')).toBe('text/x-python; charset=utf-8');
expect(mimeFor('FOO.JSON')).toBe('application/json; charset=utf-8');
});
});

View file

@ -1,5 +1,5 @@
// @ts-nocheck
import assert from 'node:assert/strict';
import type Database from 'better-sqlite3';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
@ -16,7 +16,7 @@ import {
} from '../src/db.js';
import { composeProjectDisplayStatus } from '../src/server.js';
const tempDirs = [];
const tempDirs: string[] = [];
afterEach(() => {
closeDatabase();
@ -25,13 +25,13 @@ afterEach(() => {
}
});
function createDb() {
function createDb(): Database.Database {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'od-project-status-'));
tempDirs.push(dir);
return openDatabase(dir, { dataDir: path.join(dir, '.od') });
}
function seedProject(db, projectId, runStatus = 'succeeded') {
function seedProject(db: Database.Database, projectId: string, runStatus = 'succeeded') {
insertProject(db, {
id: projectId,
name: projectId,
@ -56,7 +56,13 @@ function seedProject(db, projectId, runStatus = 'succeeded') {
return `${projectId}-conversation`;
}
function addMessage(db, conversationId, id, role, content) {
function addMessage(
db: Database.Database,
conversationId: string,
id: string,
role: 'user' | 'assistant',
content: string,
) {
upsertMessage(db, conversationId, { id, role, content });
}

View file

@ -1,28 +1,39 @@
// @ts-nocheck
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { EventEmitter } from 'node:events';
import type { FSWatcher } from 'chokidar';
import { afterEach, describe, expect, it } from 'vitest';
import {
_activeWatcherCount,
_resetForTests,
subscribe,
type ProjectWatchEvent,
type ProjectWatcherOptions,
} from '../src/project-watchers.js';
type WatcherFactoryOptions = Required<Pick<ProjectWatcherOptions, 'ignored' | 'awaitWriteFinish'>>;
function createMockWatcher(): FSWatcher {
const watcher = new EventEmitter() as EventEmitter & { close: () => Promise<void> };
watcher.close = async () => { factoryCloses++; };
return watcher as unknown as FSWatcher;
}
function fakeFactory() {
return (dir, _opts) => ({
return (dir: string, _opts: WatcherFactoryOptions) => ({
dir,
watcher: { close: async () => { factoryCloses++; } },
watcher: createMockWatcher(),
ready: Promise.resolve(),
subscribers: new Set(),
subscribers: new Set<(evt: ProjectWatchEvent) => void>(),
closing: null,
});
}
let factoryCloses = 0;
const FAST_WATCH_OPTIONS = { awaitWriteFinish: false };
const FAST_WATCH_OPTIONS: ProjectWatcherOptions = { awaitWriteFinish: false };
afterEach(async () => {
await _resetForTests();
@ -36,8 +47,11 @@ async function makeProjectsRoot() {
return { root, projectId };
}
function waitFor(predicate, { timeout = 2000, interval = 25 } = {}) {
return new Promise((resolve, reject) => {
function waitFor(
predicate: () => boolean,
{ timeout = 2000, interval = 25 }: { timeout?: number; interval?: number } = {},
): Promise<void> {
return new Promise<void>((resolve, reject) => {
const started = Date.now();
const tick = () => {
try {
@ -52,6 +66,10 @@ function waitFor(predicate, { timeout = 2000, interval = 25 } = {}) {
});
}
function assertWatcher(watcher: FSWatcher | undefined): asserts watcher is FSWatcher {
expect(watcher).toBeDefined();
}
describe('project-watchers (refcounting)', () => {
it('lazy-creates a watcher on first subscribe and closes on last unsubscribe', async () => {
const { root, projectId } = await makeProjectsRoot();
@ -109,7 +127,7 @@ describe('project-watchers (refcounting)', () => {
describe('project-watchers (real chokidar)', () => {
it('emits file-changed events on add / change / unlink', async () => {
const { root, projectId } = await makeProjectsRoot();
const events = [];
const events: ProjectWatchEvent[] = [];
const sub = subscribe(root, projectId, (e) => events.push(e), FAST_WATCH_OPTIONS);
await sub.ready;
@ -141,7 +159,7 @@ describe('project-watchers (real chokidar)', () => {
const projectId = 'proj-' + Math.random().toString(36).slice(2, 10);
await mkdir(path.join(projectsRoot, projectId, 'prototype'), { recursive: true });
const events = [];
const events: ProjectWatchEvent[] = [];
const sub = subscribe(projectsRoot, projectId, (e) => events.push(e), FAST_WATCH_OPTIONS);
await sub.ready;
@ -160,7 +178,7 @@ describe('project-watchers (real chokidar)', () => {
it('ignores files inside .od/ and node_modules/', async () => {
const { root, projectId } = await makeProjectsRoot();
const events = [];
const events: ProjectWatchEvent[] = [];
const sub = subscribe(root, projectId, (e) => events.push(e), FAST_WATCH_OPTIONS);
await sub.ready;
@ -185,7 +203,7 @@ describe('project-watchers (real chokidar)', () => {
it('ignores files inside Python venv and cache dirs', async () => {
const { root, projectId } = await makeProjectsRoot();
const events = [];
const events: ProjectWatchEvent[] = [];
const sub = subscribe(root, projectId, (e) => events.push(e), FAST_WATCH_OPTIONS);
await sub.ready;
@ -216,13 +234,14 @@ describe('project-watchers (real chokidar)', () => {
// exceptions and could crash the daemon — taking down all routes.
const { _internalWatcherForTests } = await import('../src/project-watchers.js');
const { root, projectId } = await makeProjectsRoot();
const events = [];
const events: ProjectWatchEvent[] = [];
const sub = subscribe(root, projectId, (e) => events.push(e), FAST_WATCH_OPTIONS);
await sub.ready;
try {
const watcher = _internalWatcherForTests(root, projectId);
expect(watcher).toBeDefined();
assertWatcher(watcher);
// The listener must be registered — listenerCount > 0 proves it.
expect(watcher.listenerCount('error')).toBeGreaterThan(0);
@ -268,7 +287,7 @@ describe('project-watchers (chokidar options)', () => {
throw err;
}
const events = [];
const events: ProjectWatchEvent[] = [];
const sub = subscribe(dataRoot, projectId, (e) => events.push(e), FAST_WATCH_OPTIONS);
await sub.ready;

View file

@ -55,6 +55,7 @@ describe('API proxy routes', () => {
'https://api.example.com/v1/chat/completions',
expect.objectContaining({
headers: expect.objectContaining({ Authorization: 'Bearer sk-test' }),
redirect: 'error',
}),
);
});
@ -178,6 +179,7 @@ describe('API proxy routes', () => {
'http://localhost:11434/v1/chat/completions',
expect.objectContaining({
headers: expect.objectContaining({ Authorization: 'Bearer sk-local' }),
redirect: 'error',
}),
);
});
@ -229,10 +231,15 @@ describe('API proxy routes', () => {
});
it.each([
'http://0.0.0.0:11434/v1',
'http://100.64.0.1:11434/v1',
'http://169.254.169.254/latest/meta-data',
'http://224.0.0.1:11434/v1',
'http://[::]/v1',
'http://[::ffff:192.168.1.50]:11434/v1',
'http://[fd00::1]:11434/v1',
'http://[fe80::1]:11434/v1',
'http://[::ffff:192.168.1.50]:11434/v1',
])('blocks internal IPv6 API base URL %s before proxying', async (blockedBaseUrl) => {
])('blocks local and private API base URL form %s before proxying', async (privateBaseUrl) => {
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
@ -240,7 +247,7 @@ describe('API proxy routes', () => {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
baseUrl: blockedBaseUrl,
baseUrl: privateBaseUrl,
apiKey: 'sk-private',
model: 'private-model',
messages: [{ role: 'user', content: 'hello' }],
@ -298,6 +305,57 @@ describe('API proxy routes', () => {
'https://resource.openai.azure.com/openai/deployments/deployment-one/chat/completions?api-version=2024-10-21',
);
expect(upstreamInit?.headers).toMatchObject({ 'api-key': 'azure-key' });
expect(upstreamInit?.redirect).toBe('error');
});
it.each([
['anthropic', 'https://api.anthropic.com/v1/messages'],
['openai', 'https://api.openai.com/v1/chat/completions'],
[
'azure',
'https://resource.openai.azure.com/openai/deployments/model-one/chat/completions?api-version=2024-10-21',
],
[
'google',
'https://generativelanguage.googleapis.com/v1beta/models/model-one:streamGenerateContent?alt=sse',
],
])('disables upstream redirects for %s proxy requests', async (provider, expectedUrl) => {
const fetchMock = vi.fn((input: FetchInput, init?: FetchInit) => {
const url = String(input);
if (url.startsWith(baseUrl)) return realFetch(input, init);
if (url === expectedUrl && init?.redirect === 'error') {
return Promise.reject(new TypeError('fetch failed: redirect blocked'));
}
return Promise.resolve(sseResponse('data: [DONE]\n\n'));
});
vi.stubGlobal('fetch', fetchMock);
const requestBody: Record<string, unknown> = {
baseUrl:
provider === 'azure'
? 'https://resource.openai.azure.com'
: provider === 'google'
? 'https://generativelanguage.googleapis.com'
: provider === 'anthropic'
? 'https://api.anthropic.com'
: 'https://api.openai.com',
apiKey: `${provider}-key`,
model: 'model-one',
messages: [{ role: 'user', content: 'hello' }],
};
if (provider === 'azure') requestBody.apiVersion = '2024-10-21';
const res = await realFetch(`${baseUrl}/api/proxy/${provider}/stream`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(requestBody),
});
const text = await res.text();
expect(text).toContain('event: error');
const [upstreamUrl, upstreamInit] = fetchMock.mock.calls[0]!;
expect(String(upstreamUrl)).toBe(expectedUrl);
expect(upstreamInit?.redirect).toBe('error');
});
it('surfaces Gemini safety blocks as proxy errors', async () => {
@ -342,6 +400,7 @@ describe('API proxy routes', () => {
});
const [, upstreamInit] = fetchMock.mock.calls[0]!;
expect(upstreamInit?.redirect).toBe('error');
expect(JSON.parse(String(upstreamInit?.body))).toMatchObject({
generationConfig: { maxOutputTokens: 1234 },
});

View file

@ -1,10 +1,12 @@
// @ts-nocheck
import { test } from 'vitest';
import assert from 'node:assert/strict';
import { Buffer } from 'node:buffer';
import { createQoderStreamHandler } from '../src/qoder-stream.js';
function parseLines(lines) {
const events = [];
type QoderEvent = Record<string, unknown>;
function parseLines(lines: string[]): QoderEvent[] {
const events: QoderEvent[] = [];
const handler = createQoderStreamHandler((event) => events.push(event));
for (const line of lines) {
handler.feed(`${line}\n`);
@ -35,6 +37,26 @@ test('qoder stream parser maps system init to status', () => {
]);
});
test('qoder stream parser decodes stdout buffer chunks as utf8 JSONL', () => {
const events: QoderEvent[] = [];
const handler = createQoderStreamHandler((event) => events.push(event));
handler.feed(
Buffer.from(
`${JSON.stringify({
type: 'assistant',
message: { content: [{ type: 'text', text: 'Buffered output' }] },
})}\n`,
'utf8',
),
);
handler.flush();
assert.deepEqual(events, [
{ type: 'text_delta', delta: 'Buffered output' },
]);
});
test('qoder stream parser maps assistant text content blocks to text deltas', () => {
const events = parseLines([
JSON.stringify({
@ -213,7 +235,7 @@ test('qoder stream parser forwards unknown and malformed lines as raw events', (
});
test('qoder stream parser flushes a trailing line without newline', () => {
const events = [];
const events: QoderEvent[] = [];
const handler = createQoderStreamHandler((event) => events.push(event));
handler.feed(
JSON.stringify({

View file

@ -0,0 +1,93 @@
import { EventEmitter } from 'node:events';
import { describe, expect, it, vi } from 'vitest';
import { createChatRunService } from '../src/runs.js';
describe('chat run service shutdown', () => {
it('cancels active runs and terminates their child process during daemon shutdown', async () => {
const runs = createRuns();
const child = new FakeChildProcess({ closeOn: 'SIGTERM' });
const run = runs.create({ projectId: 'project-1', conversationId: 'conv-1' });
run.status = 'running';
(run as any).child = child;
const wait = runs.wait(run);
await runs.shutdownActive({ graceMs: 10 });
expect(child.signals).toEqual(['SIGTERM']);
expect(run.status).toBe('canceled');
expect(run.cancelRequested).toBe(true);
expect(run.signal).toBe('SIGTERM');
await expect(wait).resolves.toMatchObject({ status: 'canceled', signal: 'SIGTERM' });
expect(run.events.at(-1)).toMatchObject({
event: 'end',
data: { status: 'canceled', signal: 'SIGTERM' },
});
});
it('escalates to SIGKILL when a child ignores the shutdown SIGTERM grace window', async () => {
const runs = createRuns();
const child = new FakeChildProcess({ closeOn: 'SIGKILL' });
const run = runs.create();
run.status = 'running';
(run as any).child = child;
await runs.shutdownActive({ graceMs: 1 });
expect(child.signals).toEqual(['SIGTERM', 'SIGKILL']);
expect(run.status).toBe('canceled');
});
it('uses adapter abort before process signals for ACP-style runs', async () => {
const runs = createRuns();
const child = new FakeChildProcess({ closeOn: 'SIGTERM' });
const abort = vi.fn();
const run = runs.create();
run.status = 'running';
(run as any).child = child;
(run as any).acpSession = { abort };
await runs.shutdownActive({ graceMs: 10 });
expect(abort).toHaveBeenCalledTimes(1);
expect(child.signals).toEqual(['SIGTERM']);
expect(run.status).toBe('canceled');
});
});
function createRuns() {
return createChatRunService({
createSseResponse: () => ({
send: vi.fn(() => true),
end: vi.fn(),
cleanup: vi.fn(),
}),
createSseErrorPayload: (code: string, message: string) => ({ error: { code, message } }),
shutdownGraceMs: 10,
ttlMs: 60_000,
});
}
class FakeChildProcess extends EventEmitter {
exitCode: number | null = null;
signalCode: string | null = null;
killed = false;
signals: string[] = [];
constructor(private readonly options: { closeOn: 'SIGTERM' | 'SIGKILL' }) {
super();
}
kill(signal: string): boolean {
this.killed = true;
this.signals.push(signal);
if (signal === this.options.closeOn) {
this.signalCode = signal;
queueMicrotask(() => {
this.emit('exit', null, signal);
this.emit('close', null, signal);
});
}
return true;
}
}

View file

@ -1,4 +1,3 @@
// @ts-nocheck
import http from 'node:http';
import express from 'express';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';

View file

@ -0,0 +1,53 @@
import { createServer, type Server } from 'node:http';
import { afterEach, describe, expect, it } from 'vitest';
import { closeHttpServer } from '../src/sidecar/server.js';
describe('daemon sidecar HTTP shutdown', () => {
let server: Server | null = null;
afterEach(async () => {
if (!server?.listening) return;
await new Promise<void>((resolve) => server!.close(() => resolve()));
server = null;
});
it('force-closes long-lived responses when the graceful close timeout expires', async () => {
server = createServer((_req, res) => {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
});
res.write('event: open\ndata: {}\n\n');
});
await listen(server);
const response = await fetch(`http://127.0.0.1:${port(server)}/events`);
expect(response.status).toBe(200);
const startedAt = Date.now();
await closeHttpServer(server, { closeTimeoutMs: 50, idleCloseMs: 5 });
expect(Date.now() - startedAt).toBeLessThan(1_000);
expect(server.listening).toBe(false);
});
});
async function listen(server: Server): Promise<void> {
await new Promise<void>((resolve, reject) => {
server.once('error', reject);
server.listen(0, '127.0.0.1', () => {
server.off('error', reject);
resolve();
});
});
}
function port(server: Server): number {
const address = server.address();
if (!address || typeof address === 'string') {
throw new Error('server did not bind to a TCP port');
}
return address.port;
}

View file

@ -1,4 +1,3 @@
// @ts-nocheck
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
@ -18,7 +17,7 @@ import {
// saved against the old id. These tests pin the alias map and the lookup
// helper that every server-side resolver must go through.
let skillsRoot;
let skillsRoot: string;
beforeAll(async () => {
skillsRoot = await mkdtemp(path.join(tmpdir(), 'od-skills-aliases-'));
@ -90,7 +89,7 @@ describe('findSkillById', () => {
it('resolves a project saved with the old editorial-collage id to the renamed skill', async () => {
const skills = await listSkills(skillsRoot);
const skill = findSkillById(skills, 'editorial-collage');
expect(skill).toBeDefined();
if (!skill) throw new Error('editorial-collage skill not found');
expect(skill.id).toBe('open-design-landing');
expect(skill.body).toContain('body');
});
@ -98,7 +97,7 @@ describe('findSkillById', () => {
it('resolves a project saved with the old editorial-collage-deck id to the renamed deck skill', async () => {
const skills = await listSkills(skillsRoot);
const skill = findSkillById(skills, 'editorial-collage-deck');
expect(skill).toBeDefined();
if (!skill) throw new Error('editorial-collage-deck skill not found');
expect(skill.id).toBe('open-design-landing-deck');
});

View file

@ -11,15 +11,23 @@ import { SKILLS_CWD_ALIAS } from '../src/cwd-aliases.js';
import {
deleteUserSkill,
importUserSkill,
listSkillFiles,
listSkills,
slugifySkillName,
updateUserSkill,
} from '../src/skills.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const repoRoot = path.resolve(__dirname, '../../..');
const skillsRoot = path.join(repoRoot, 'skills');
const liveArtifactRoot = path.join(skillsRoot, 'live-artifact');
// `live-artifact`, `dcf-valuation`, `x-research`, and `last30days` were
// reclassified as design templates under the Phase 0 split (see
// specs/current/skills-and-design-templates.md). The body/preamble
// expectations below still apply, but they now read from the design
// templates root rather than skills/.
const designTemplatesRoot = path.join(repoRoot, 'design-templates');
const liveArtifactRoot = path.join(designTemplatesRoot, 'live-artifact');
type SkillCatalogEntry = {
id: string;
@ -67,10 +75,10 @@ function writeSkill(
describe('listSkills', () => {
it('includes the built-in live-artifact skill catalog entry', async () => {
const skills = await listSkills(skillsRoot);
const skills = await listSkills(designTemplatesRoot);
const skill = skills.find((entry: { id: string }) => entry.id === 'live-artifact');
expect(skill).toBeTruthy();
if (!skill) throw new Error('live-artifact skill not found');
expect(skill).toMatchObject({
id: 'live-artifact',
name: 'live-artifact',
@ -94,7 +102,7 @@ describe('listSkills', () => {
});
it('includes the DCF valuation, X research, and Last30Days research skills', async () => {
const skills = await listSkills(skillsRoot);
const skills = await listSkills(designTemplatesRoot);
const byId = new Map(
(skills as SkillCatalogEntry[]).map((skill) => [skill.id, skill]),
);
@ -161,7 +169,8 @@ describe('listSkills preamble', () => {
const skills = await listSkills(root);
expect(skills).toHaveLength(1);
const [skill] = skills;
const skill = skills[0];
if (!skill) throw new Error('demo-skill not found');
// The cwd-relative alias path is the primary one — that's what makes
// the agent stay inside its working directory when reading skill
@ -192,7 +201,8 @@ describe('listSkills preamble', () => {
const skills = await listSkills(root);
expect(skills).toHaveLength(1);
const [skill] = skills;
const skill = skills[0];
if (!skill) throw new Error('orbit-style skill not found');
expect(skill.body).toContain(`${SKILLS_CWD_ALIAS}/orbit-style/`);
expect(skill.body).toContain(`${SKILLS_CWD_ALIAS}/orbit-style/example.html`);
@ -208,7 +218,8 @@ describe('listSkills preamble', () => {
const skills = await listSkills(root);
expect(skills).toHaveLength(1);
const [skill] = skills;
const skill = skills[0];
if (!skill) throw new Error('magazine-web-ppt skill not found');
// `id`/`name` reflect the frontmatter value (used elsewhere as a stable
// public id), but the on-disk alias path must use the actual folder
@ -227,7 +238,8 @@ describe('listSkills preamble', () => {
const skills = await listSkills(root);
expect(skills).toHaveLength(1);
const [skill] = skills;
const skill = skills[0];
if (!skill) throw new Error('lone-skill not found');
expect(skill.body).not.toContain(SKILLS_CWD_ALIAS);
expect(skill.body).not.toContain('Skill root');
@ -272,8 +284,9 @@ describe('listSkills multi-root + source tagging', () => {
const skills = await listSkills([userRoot, builtInRoot]);
expect(skills).toHaveLength(1);
expect(skills[0].source).toBe('user');
expect(skills[0].body).toContain('Override body');
const shadowed = skills[0]!;
expect(shadowed.source).toBe('user');
expect(shadowed.body).toContain('Override body');
rmSync(userRoot, { recursive: true, force: true });
rmSync(builtInRoot, { recursive: true, force: true });
@ -306,10 +319,11 @@ describe('importUserSkill / deleteUserSkill', () => {
const skills = await listSkills(root);
expect(skills).toHaveLength(1);
expect(skills[0].id).toBe('Code Review');
expect(skills[0].triggers).toEqual(['code review', 'review my diff']);
const imported = skills[0]!;
expect(imported.id).toBe('Code Review');
expect(imported.triggers).toEqual(['code review', 'review my diff']);
// First (and only) root is treated as the user root.
expect(skills[0].source).toBe('user');
expect(imported.source).toBe('user');
// Importing the same name again surfaces a CONFLICT error.
await expect(
@ -346,3 +360,99 @@ describe('importUserSkill / deleteUserSkill', () => {
}
});
});
describe('updateUserSkill', () => {
it('writes a SKILL.md and shadows a built-in entry on next listSkills', async () => {
const userRoot = fresh();
const builtInRoot = fresh();
try {
writeSkill(builtInRoot, 'shared-id', {
description: 'Original built-in.',
body: '# Original',
});
const result = await updateUserSkill(userRoot, {
name: 'shared-id',
description: 'User override.',
body: '# Override',
triggers: ['shared trigger'],
});
expect(result.slug).toBe('shared-id');
expect(result.dir).toBe(path.join(userRoot, 'shared-id'));
const skills = await listSkills([userRoot, builtInRoot]);
expect(skills).toHaveLength(1);
const shadowed = skills[0]!;
expect(shadowed.source).toBe('user');
expect(shadowed.body).toContain('Override');
expect(shadowed.triggers).toEqual(['shared trigger']);
} finally {
rmSync(userRoot, { recursive: true, force: true });
rmSync(builtInRoot, { recursive: true, force: true });
}
});
it('rejects empty bodies and impossibly-named skills', async () => {
const root = fresh();
try {
await expect(
updateUserSkill(root, { name: 'demo', body: ' ' }),
).rejects.toMatchObject({ code: 'BAD_REQUEST' });
await expect(
updateUserSkill(root, { name: '..', body: '# body' }),
).rejects.toMatchObject({ code: 'BAD_REQUEST' });
} finally {
rmSync(root, { recursive: true, force: true });
}
});
});
describe('listSkillFiles', () => {
it('returns a flat sorted file/directory list with byte sizes', async () => {
const root = fresh();
try {
writeSkill(root, 'demo-files', { withAttachments: true });
mkdirSync(path.join(root, 'demo-files', 'references'), { recursive: true });
writeFileSync(
path.join(root, 'demo-files', 'references', 'notes.md'),
'# notes',
);
const entries = await listSkillFiles(path.join(root, 'demo-files'));
const byPath = new Map(entries.map((entry) => [entry.path, entry]));
const skillMd = byPath.get('SKILL.md');
const assetsDir = byPath.get('assets');
const templateHtml = byPath.get('assets/template.html');
const referencesDir = byPath.get('references');
const notesMd = byPath.get('references/notes.md');
if (!skillMd || !assetsDir || !templateHtml || !referencesDir || !notesMd) {
throw new Error('expected file tree to include SKILL.md + assets + references');
}
expect(skillMd.kind).toBe('file');
expect(skillMd.size).toBeGreaterThan(0);
expect(assetsDir.kind).toBe('directory');
expect(assetsDir.size).toBeNull();
expect(templateHtml.kind).toBe('file');
expect(templateHtml.size).toBeGreaterThan(0);
expect(referencesDir.kind).toBe('directory');
expect(notesMd.kind).toBe('file');
} finally {
rmSync(root, { recursive: true, force: true });
}
});
it('skips dotfiles and returns an empty list for a missing directory', async () => {
const root = fresh();
try {
writeSkill(root, 'with-dotfile');
writeFileSync(path.join(root, 'with-dotfile', '.DS_Store'), 'x');
const entries = await listSkillFiles(path.join(root, 'with-dotfile'));
expect(entries.find((entry) => entry.path === '.DS_Store')).toBeUndefined();
const missing = await listSkillFiles(path.join(root, 'no-such-skill'));
expect(missing).toEqual([]);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
});

View file

@ -1,5 +1,5 @@
// @ts-nocheck
import { EventEmitter } from 'node:events';
import type { Response } from 'express';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { createCompatApiErrorResponse, createSseResponse } from '../src/server.js';
@ -11,7 +11,7 @@ afterEach(() => {
describe('createSseResponse', () => {
it('sets SSE headers and sends JSON app events', () => {
const res = new FakeResponse();
const sse = createSseResponse(res, { keepAliveIntervalMs: 0 });
const sse = createSseResponse(res as unknown as Response, { keepAliveIntervalMs: 0 });
expect(res.headers).toEqual({
'Cache-Control': 'no-cache, no-transform',
@ -27,7 +27,7 @@ describe('createSseResponse', () => {
it('can attach SSE event ids for resumable streams', () => {
const res = new FakeResponse();
const sse = createSseResponse(res, { keepAliveIntervalMs: 0 });
const sse = createSseResponse(res as unknown as Response, { keepAliveIntervalMs: 0 });
expect(sse.send('stdout', { chunk: 'hello' }, 12)).toBe(true);
@ -36,7 +36,7 @@ describe('createSseResponse', () => {
it('emits heartbeat comments before real events', () => {
const res = new FakeResponse();
const sse = createSseResponse(res, { keepAliveIntervalMs: 0 });
const sse = createSseResponse(res as unknown as Response, { keepAliveIntervalMs: 0 });
expect(sse.writeKeepAlive()).toBe(true);
expect(sse.send('end', {})).toBe(true);
@ -46,7 +46,7 @@ describe('createSseResponse', () => {
it('clears interval heartbeat on close', () => {
vi.useFakeTimers();
const res = new FakeResponse();
createSseResponse(res, { keepAliveIntervalMs: 10 });
createSseResponse(res as unknown as Response, { keepAliveIntervalMs: 10 });
vi.advanceTimersByTime(10);
expect(res.writes).toEqual([': keepalive\n\n']);
@ -58,7 +58,7 @@ describe('createSseResponse', () => {
it('skips writes after the response ends', () => {
const res = new FakeResponse();
const sse = createSseResponse(res, { keepAliveIntervalMs: 0 });
const sse = createSseResponse(res as unknown as Response, { keepAliveIntervalMs: 0 });
sse.end();
@ -97,14 +97,14 @@ describe('createCompatApiErrorResponse', () => {
});
class FakeResponse extends EventEmitter {
headers = {};
writes = [];
headers: Record<string, string> = {};
writes: string[] = [];
destroyed = false;
writableEnded = false;
flushed = false;
ended = false;
setHeader(name, value) {
setHeader(name: string, value: string) {
this.headers[name] = value;
}
@ -112,7 +112,7 @@ class FakeResponse extends EventEmitter {
this.flushed = true;
}
write(chunk) {
write(chunk: string) {
this.writes.push(chunk);
return true;
}

View file

@ -1,4 +1,3 @@
// @ts-nocheck
// Persisted event shape under test is `PersistedAgentEvent` from
// packages/contracts/src/api/chat.ts (the discriminator is `kind`, the
// thinking field is `text`). The daemon's claude-stream emits a different
@ -17,6 +16,7 @@
// because it returns the underlying CJS `module.exports` object.
import { afterEach, describe, expect, it, vi } from 'vitest';
import type Database from 'better-sqlite3';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
@ -46,7 +46,37 @@ afterEach(() => {
projectsRoot = null;
});
function setup(opts: { skipMkdir?: boolean } = {}): { db: any; projectsRoot: string } {
type TranscriptLine = Record<string, unknown>;
type TranscriptLines = TranscriptLine[];
type PersistedAgentEvent =
| { kind: 'status'; label: string; detail?: string }
| { kind: 'text'; text: string }
| { kind: 'thinking'; text: string }
| { kind: 'tool_use'; id: string; name: string; input: unknown }
| { kind: 'tool_result'; toolUseId: string; content: string; isError: boolean }
| { kind: 'usage'; inputTokens?: number; outputTokens?: number; costUsd?: number; durationMs?: number }
| { kind: 'raw'; line: string };
type ChatAttachment = { path: string; name: string; kind: string; size?: number };
type ChatCommentAttachment = {
id: string;
order: number;
filePath: string;
elementId: string;
selector: string;
label: string;
comment: string;
currentText: string;
pagePosition: { x: number; y: number };
htmlHint: string;
};
function line(lines: TranscriptLines, index: number): TranscriptLine {
const item = lines[index];
if (!item) throw new Error(`missing transcript line ${index}`);
return item;
}
function setup(opts: { skipMkdir?: boolean } = {}): { db: Database.Database; projectsRoot: string } {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'od-tx-'));
const db = openDatabase(tempDir);
insertProject(db, {
@ -62,16 +92,16 @@ function setup(opts: { skipMkdir?: boolean } = {}): { db: any; projectsRoot: str
return { db, projectsRoot };
}
function readLines(filePath: string): any[] {
function readLines(filePath: string): TranscriptLines {
const raw = fs.readFileSync(filePath, 'utf8');
expect(raw.endsWith('\n')).toBe(true);
return raw
.split('\n')
.filter((l) => l.length > 0)
.map((l) => JSON.parse(l));
.map((l) => JSON.parse(l) as TranscriptLine) as TranscriptLines;
}
function seedConversation(db: any, opts: { id: string; createdAt: number; updatedAt?: number; title?: string | null }) {
function seedConversation(db: Database.Database, opts: { id: string; createdAt: number; updatedAt?: number; title?: string | null }) {
insertConversation(db, {
id: opts.id,
projectId: PROJECT_ID,
@ -82,15 +112,15 @@ function seedConversation(db: any, opts: { id: string; createdAt: number; update
}
function seedMessage(
db: any,
db: Database.Database,
conversationId: string,
m: {
id: string;
role: 'user' | 'assistant';
content?: string;
events?: any[];
attachments?: any[];
commentAttachments?: any[];
events?: PersistedAgentEvent[];
attachments?: ChatAttachment[];
commentAttachments?: ChatCommentAttachment[];
},
) {
upsertMessage(db, conversationId, {
@ -115,7 +145,7 @@ describe('exportProjectTranscript', () => {
const lines = readLines(result.path);
expect(lines).toHaveLength(1);
expect(lines[0]).toEqual({
expect(line(lines, 0)).toEqual({
kind: 'header',
schemaVersion: 2,
projectId: PROJECT_ID,
@ -146,26 +176,26 @@ describe('exportProjectTranscript', () => {
const lines = readLines(result.path);
expect(lines).toHaveLength(4);
expect(lines[0].kind).toBe('header');
expect(lines[0].schemaVersion).toBe(2);
expect(lines[0].conversationCount).toBe(1);
expect(lines[0].messageCount).toBe(2);
expect(lines[1]).toEqual({
expect(line(lines, 0).kind).toBe('header');
expect(line(lines, 0).schemaVersion).toBe(2);
expect(line(lines, 0).conversationCount).toBe(1);
expect(line(lines, 0).messageCount).toBe(2);
expect(line(lines, 1)).toEqual({
kind: 'conversation',
id: 'c1',
title: 'Greeting',
createdAt: 100,
updatedAt: expect.any(Number),
});
expect(lines[2].kind).toBe('message');
expect(lines[2].conversationId).toBe('c1');
expect(lines[2].id).toBe('m1');
expect(lines[2].role).toBe('user');
expect(lines[2].position).toBe(0);
expect(lines[2].blocks).toEqual([{ type: 'text', text: 'hello' }]);
expect(lines[3].id).toBe('m2');
expect(lines[3].position).toBe(1);
expect(lines[3].blocks).toEqual([{ type: 'text', text: 'world' }]);
expect(line(lines, 2).kind).toBe('message');
expect(line(lines, 2).conversationId).toBe('c1');
expect(line(lines, 2).id).toBe('m1');
expect(line(lines, 2).role).toBe('user');
expect(line(lines, 2).position).toBe(0);
expect(line(lines, 2).blocks).toEqual([{ type: 'text', text: 'hello' }]);
expect(line(lines, 3).id).toBe('m2');
expect(line(lines, 3).position).toBe(1);
expect(line(lines, 3).blocks).toEqual([{ type: 'text', text: 'world' }]);
});
it('coalesces adjacent text events into a single text block', () => {
@ -182,7 +212,7 @@ describe('exportProjectTranscript', () => {
});
const lines = readLines(exportProjectTranscript(db, projectsRoot, PROJECT_ID, { now: FIXED_NOW }).path);
const msg = lines[2];
const msg = line(lines, 2);
expect(msg.blocks).toEqual([{ type: 'text', text: 'hello world' }]);
});
@ -201,7 +231,7 @@ describe('exportProjectTranscript', () => {
});
const lines = readLines(exportProjectTranscript(db, projectsRoot, PROJECT_ID, { now: FIXED_NOW }).path);
expect(lines[2].blocks).toEqual([
expect(line(lines, 2).blocks).toEqual([
{ type: 'text', text: 'I will read.' },
{ type: 'tool_use', id: 'tu_1', name: 'Read', input: { path: '/x' } },
{ type: 'tool_result', toolUseId: 'tu_1', content: 'file contents', isError: false },
@ -225,7 +255,7 @@ describe('exportProjectTranscript', () => {
});
const lines = readLines(exportProjectTranscript(db, projectsRoot, PROJECT_ID, { now: FIXED_NOW }).path);
expect(lines[2].blocks).toEqual([
expect(line(lines, 2).blocks).toEqual([
{ type: 'thinking', thinking: 'reasoning' },
{ type: 'text', text: 'answer' },
]);
@ -245,7 +275,7 @@ describe('exportProjectTranscript', () => {
});
const lines = readLines(exportProjectTranscript(db, projectsRoot, PROJECT_ID, { now: FIXED_NOW }).path);
expect(lines[2].blocks).toEqual([
expect(line(lines, 2).blocks).toEqual([
{ type: 'thinking', thinking: 'plan' },
{ type: 'text', text: 'ok' },
{ type: 'tool_use', id: 't', name: 'X', input: {} },
@ -266,7 +296,7 @@ describe('exportProjectTranscript', () => {
});
const lines = readLines(exportProjectTranscript(db, projectsRoot, PROJECT_ID, { now: FIXED_NOW }).path);
expect(lines[2].blocks).toEqual([
expect(line(lines, 2).blocks).toEqual([
{ type: 'text', text: 'pre' },
{ type: 'thinking', thinking: 'mid' },
{ type: 'text', text: 'post' },
@ -291,7 +321,7 @@ describe('exportProjectTranscript', () => {
});
const lines = readLines(exportProjectTranscript(db, projectsRoot, PROJECT_ID, { now: FIXED_NOW }).path);
expect(lines[2].blocks).toEqual([
expect(line(lines, 2).blocks).toEqual([
{ type: 'thinking', thinking: 'first second third' },
{ type: 'text', text: 'visible' },
]);
@ -341,8 +371,8 @@ describe('exportProjectTranscript', () => {
});
const lines = readLines(exportProjectTranscript(db, projectsRoot, PROJECT_ID, { now: FIXED_NOW }).path);
expect(lines[2].id).toBe('m-user');
expect(lines[2].blocks).toEqual([{ type: 'text', text: 'Make me a landing page.' }]);
expect(line(lines, 2).id).toBe('m-user');
expect(line(lines, 2).blocks).toEqual([{ type: 'text', text: 'Make me a landing page.' }]);
});
it('prefers event-derived blocks over the content fallback when both are present', () => {
@ -363,7 +393,7 @@ describe('exportProjectTranscript', () => {
});
const lines = readLines(exportProjectTranscript(db, projectsRoot, PROJECT_ID, { now: FIXED_NOW }).path);
expect(lines[2].blocks).toEqual([
expect(line(lines, 2).blocks).toEqual([
{ type: 'text', text: 'final coalesced text' },
{ type: 'tool_use', id: 'tu_1', name: 'Read', input: { path: '/x' } },
]);
@ -384,8 +414,8 @@ describe('exportProjectTranscript', () => {
const result = exportProjectTranscript(db, projectsRoot, PROJECT_ID, { now: FIXED_NOW });
const lines = readLines(result.path);
expect(lines).toHaveLength(3); // header + conversation + 1 message
expect(lines[2].id).toBe('mbad');
expect(lines[2].blocks).toEqual([]);
expect(line(lines, 2).id).toBe('mbad');
expect(line(lines, 2).blocks).toEqual([]);
});
it('rejects unsafe project ids (path-traversal guard from projectDir)', () => {
@ -482,8 +512,8 @@ describe('exportProjectTranscript', () => {
const after = fs.readFileSync(finalPath, 'utf8');
expect(after).not.toContain('sentinel');
const lines = after.split('\n').filter((l) => l.length > 0).map((l) => JSON.parse(l));
expect(lines[0].kind).toBe('header');
expect(lines[2].id).toBe('m1');
expect(line(lines, 0).kind).toBe('header');
expect(line(lines, 2).id).toBe('m1');
});
// ---------- §1.5 lock contention (test #19, advisor-redesigned) ----------
@ -527,13 +557,14 @@ describe('exportProjectTranscript', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const result = exportProjectTranscript(db, projectsRoot, PROJECT_ID, { now: FIXED_NOW });
expect(warn).toHaveBeenCalledTimes(1);
expect(warn.mock.calls[0][0]).toContain('mmal');
expect(warn.mock.calls[0][0]).toContain(PROJECT_ID);
expect(warn.mock.calls[0][0]).toContain('malformed');
const warning = warn.mock.calls[0]?.[0];
expect(warning).toContain('mmal');
expect(warning).toContain(PROJECT_ID);
expect(warning).toContain('malformed');
const lines = readLines(result.path);
expect(lines[2].id).toBe('mmal');
expect(lines[2].blocks).toEqual([{ type: 'text', text: 'fallback content' }]);
expect(line(lines, 2).id).toBe('mmal');
expect(line(lines, 2).blocks).toEqual([{ type: 'text', text: 'fallback content' }]);
});
it('warns when events_json is JSON but not an array', () => {
@ -547,11 +578,12 @@ describe('exportProjectTranscript', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const result = exportProjectTranscript(db, projectsRoot, PROJECT_ID, { now: FIXED_NOW });
expect(warn).toHaveBeenCalledTimes(1);
expect(warn.mock.calls[0][0]).toContain('mobj');
expect(warn.mock.calls[0][0]).toContain('not_array');
const warning = warn.mock.calls[0]?.[0];
expect(warning).toContain('mobj');
expect(warning).toContain('not_array');
const lines = readLines(result.path);
expect(lines[2].blocks).toEqual([{ type: 'text', text: 'fallback content' }]);
expect(line(lines, 2).blocks).toEqual([{ type: 'text', text: 'fallback content' }]);
});
// ---------- §1.6 attachments (tests #22-#23) ----------
@ -591,9 +623,9 @@ describe('exportProjectTranscript', () => {
const result = exportProjectTranscript(db, projectsRoot, PROJECT_ID, { now: FIXED_NOW });
const lines = readLines(result.path);
expect(lines[0].attachmentCount).toBe(3);
expect(lines[0].commentAttachmentCount).toBe(1);
expect(lines[0].attachmentsInlined).toBe(false);
expect(line(lines, 0).attachmentCount).toBe(3);
expect(line(lines, 0).commentAttachmentCount).toBe(1);
expect(line(lines, 0).attachmentsInlined).toBe(false);
});
it('per-message line carries attachments / commentAttachments only when present', () => {
@ -628,6 +660,8 @@ describe('exportProjectTranscript', () => {
const lines = readLines(exportProjectTranscript(db, projectsRoot, PROJECT_ID, { now: FIXED_NOW }).path);
const withAtt = lines.find((l) => l.id === 'm-with');
const bare = lines.find((l) => l.id === 'm-bare');
if (!withAtt) throw new Error('m-with transcript line not found');
if (!bare) throw new Error('m-bare transcript line not found');
expect(withAtt.attachments).toEqual([
{ path: 'a.png', name: 'a.png', kind: 'image', size: 99 },
@ -651,8 +685,8 @@ describe('exportProjectTranscript', () => {
const result = exportProjectTranscript(db, projectsRoot, PROJECT_ID, { now: FIXED_NOW });
expect(fs.existsSync(result.path)).toBe(true);
const lines = readLines(result.path);
expect(lines[0].kind).toBe('header');
expect(lines[2].id).toBe('m1');
expect(line(lines, 0).kind).toBe('header');
expect(line(lines, 2).id).toBe('m1');
});
// ---------- Codex P2 (3188524878): thinking-start boundary preservation ----------
@ -678,7 +712,7 @@ describe('exportProjectTranscript', () => {
});
const lines = readLines(exportProjectTranscript(db, projectsRoot, PROJECT_ID, { now: FIXED_NOW }).path);
expect(lines[2].blocks).toEqual([
expect(line(lines, 2).blocks).toEqual([
{ type: 'thinking', thinking: 'ab' },
{ type: 'thinking', thinking: 'cd' },
]);

View file

@ -33,8 +33,8 @@
"@open-design/platform": "workspace:*",
"@open-design/sidecar": "workspace:*",
"@open-design/sidecar-proto": "workspace:*",
"next": "^16.2.4",
"openai": "^6.35.0",
"next": "^16.2.5",
"openai": "^6.36.0",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
@ -44,7 +44,7 @@
"@types/node": "^20.17.10",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"jsdom": "^29.1.0",
"jsdom": "29.1.0",
"typescript": "^5.6.3",
"vitest": "^2.1.8"
},

View file

@ -13,6 +13,7 @@ import {
fetchAppVersionInfo,
fetchAgents,
fetchDesignSystems,
fetchDesignTemplates,
fetchPromptTemplates,
fetchSkills,
} from './providers/registry';
@ -117,7 +118,13 @@ export function App() {
const [settingsInitialSection, setSettingsInitialSection] = useState<SettingsSection>('execution');
const [daemonLive, setDaemonLive] = useState(false);
const [agents, setAgents] = useState<AgentInfo[]>([]);
// Functional skills (capabilities the agent invokes mid-task) — stays
// small and lives under the Settings → Skills surface.
const [skills, setSkills] = useState<SkillSummary[]>([]);
// Design templates (rendering catalogue: decks, prototypes, image/video/
// audio templates) — sourced from /api/design-templates and shown in the
// EntryView Templates tab. See specs/current/skills-and-design-templates.md.
const [designTemplates, setDesignTemplates] = useState<SkillSummary[]>([]);
const [designSystems, setDesignSystems] = useState<DesignSystemSummary[]>([]);
const [projects, setProjects] = useState<Project[]>([]);
const [templates, setTemplates] = useState<ProjectTemplate[]>([]);
@ -218,10 +225,27 @@ export function App() {
setAgentsLoading(false);
});
// Functional skills + design templates land independently. Both
// gate `skillsLoading` together so the EntryView stops rendering
// its loader once both registries respond — neither tab would have
// a complete picture if we cleared the flag on the first reply.
let functionalReady = false;
let templatesReady = false;
const maybeClearLoading = () => {
if (functionalReady && templatesReady) setSkillsLoading(false);
};
void fetchSkills().then((list) => {
if (cancelled) return;
setSkills(list);
setSkillsLoading(false);
functionalReady = true;
maybeClearLoading();
});
void fetchDesignTemplates().then((list) => {
if (cancelled) return;
setDesignTemplates(list);
templatesReady = true;
maybeClearLoading();
});
void fetchDesignSystems().then((list) => {
@ -685,9 +709,30 @@ export function App() {
void refreshTemplates();
}, [route.kind, refreshTemplates]);
// Existing card grids (DesignsTab, ProjectView), pickers (NewProjectPanel,
// ChatComposer mention) all look skills up by id without caring whether
// the id resolves to a functional skill or a design template. Pass them
// the union so the post-split refactor stays invisible to those callers.
const allSkillSummaries = useMemo(
() => [...skills, ...designTemplates],
[skills, designTemplates],
);
const enabledSkills = useMemo(
() => skills.filter((s) => !(config.disabledSkills ?? []).includes(s.id)),
[skills, config.disabledSkills],
() =>
allSkillSummaries.filter(
(s) => !(config.disabledSkills ?? []).includes(s.id),
),
[allSkillSummaries, config.disabledSkills],
);
// Templates-only enabled subset — what the EntryView Templates gallery
// actually renders. Filtering in App keeps the EntryView prop surface
// narrow ("here are the templates the user has not disabled").
const enabledDesignTemplates = useMemo(
() =>
designTemplates.filter(
(s) => !(config.disabledSkills ?? []).includes(s.id),
),
[designTemplates, config.disabledSkills],
);
const enabledDS = useMemo(
() =>
@ -727,6 +772,7 @@ export function App() {
) : (
<EntryView
skills={enabledSkills}
designTemplates={enabledDesignTemplates}
designSystems={enabledDS}
projects={projects}
templates={templates}

View file

@ -7,12 +7,14 @@ import {
type KeyboardEvent as ReactKeyboardEvent,
type SyntheticEvent,
} from 'react';
import type { ConnectorDetail, ConnectorStatusResponse } from '@open-design/contracts';
import type { ConnectorConnectResponse, ConnectorDetail, ConnectorStatusResponse } from '@open-design/contracts';
import { useT } from '../i18n';
import type { Dict } from '../i18n/types';
import {
cancelConnectorAuthorization as cancelConnectorAuthorizationRequest,
connectConnector,
disconnectConnector,
fetchConnectorDetail,
fetchConnectorDiscovery,
fetchConnectors,
fetchConnectorStatuses,
@ -25,6 +27,16 @@ import { Icon } from './Icon';
import { CenteredLoader } from './Loading';
const CONNECTOR_CALLBACK_MESSAGE_TYPE = 'open-design:connector-connected';
const CONNECTOR_AUTH_PENDING_STORAGE_KEY = 'od-connectors-authorization-pending';
const CONNECTOR_AUTH_PENDING_POLL_MS = 2_000;
const CONNECTOR_TOOL_PREVIEW_LIMIT = 50;
const AUTHORIZATION_CANCEL_FAILED_MESSAGE = "Couldn't cancel authorization. Try again.";
interface ConnectorAuthorizationPending {
expiresAt?: string;
}
type ConnectorAuthorizationPendingState = Record<string, ConnectorAuthorizationPending>;
const COMPOSIO_LOGO_SLUG_OVERRIDES: Record<string, string> = {
google_drive: 'googledrive',
@ -201,6 +213,9 @@ function mergeConnectors(current: ConnectorDetail[], incoming: ConnectorDetail[]
...connector,
...next,
tools: next.tools.length > 0 ? next.tools : connector.tools,
toolCount: next.toolCount ?? connector.toolCount,
toolsNextCursor: next.toolsNextCursor ?? connector.toolsNextCursor,
toolsHasMore: next.toolsHasMore ?? connector.toolsHasMore,
};
});
const currentIds = new Set(current.map((connector) => connector.id));
@ -210,6 +225,137 @@ function mergeConnectors(current: ConnectorDetail[], incoming: ConnectorDetail[]
return merged;
}
function loadConnectorAuthorizationPending(): ConnectorAuthorizationPendingState {
if (typeof window === 'undefined') return {};
try {
const raw = window.sessionStorage.getItem(CONNECTOR_AUTH_PENDING_STORAGE_KEY);
if (!raw) return {};
const parsed = JSON.parse(raw) as unknown;
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {};
const pending: ConnectorAuthorizationPendingState = {};
for (const [connectorId, state] of Object.entries(parsed as Record<string, unknown>)) {
if (!connectorId) continue;
if (state && typeof state === 'object' && !Array.isArray(state)) {
const expiresAt = (state as Record<string, unknown>).expiresAt;
pending[connectorId] = typeof expiresAt === 'string' && expiresAt.trim() ? { expiresAt } : {};
} else {
pending[connectorId] = {};
}
}
return pruneConnectorAuthorizationPending(pending);
} catch {
return {};
}
}
function saveConnectorAuthorizationPending(pending: ConnectorAuthorizationPendingState): void {
if (typeof window === 'undefined') return;
try {
if (Object.keys(pending).length === 0) {
window.sessionStorage.removeItem(CONNECTOR_AUTH_PENDING_STORAGE_KEY);
} else {
window.sessionStorage.setItem(CONNECTOR_AUTH_PENDING_STORAGE_KEY, JSON.stringify(pending));
}
} catch {
/* Ignore unavailable sessionStorage. */
}
}
export function pruneConnectorAuthorizationPending(
pending: ConnectorAuthorizationPendingState,
nowMs = Date.now(),
): ConnectorAuthorizationPendingState {
const next: ConnectorAuthorizationPendingState = {};
for (const [connectorId, state] of Object.entries(pending)) {
const expiresAtMs = state.expiresAt ? Date.parse(state.expiresAt) : Number.NaN;
if (Number.isFinite(expiresAtMs) && expiresAtMs <= nowMs) continue;
next[connectorId] = state.expiresAt ? { expiresAt: state.expiresAt } : {};
}
return next;
}
export function updateConnectorAuthorizationPendingFromConnectResponse(
pending: ConnectorAuthorizationPendingState,
response: ConnectorConnectResponse,
nowMs = Date.now(),
): ConnectorAuthorizationPendingState {
const connectorId = response.connector.id;
const next = { ...pending };
if (response.auth?.kind === 'redirect_required' || response.auth?.kind === 'pending') {
next[connectorId] = response.auth.expiresAt ? { expiresAt: response.auth.expiresAt } : {};
return pruneConnectorAuthorizationPending(next, nowMs);
}
delete next[connectorId];
return pruneConnectorAuthorizationPending(next, nowMs);
}
export function updateConnectorAuthorizationPendingFromStatuses(
pending: ConnectorAuthorizationPendingState,
statuses: ConnectorStatusResponse['statuses'],
nowMs = Date.now(),
): ConnectorAuthorizationPendingState {
const next = { ...pending };
for (const [connectorId, status] of Object.entries(statuses)) {
if (status.status === 'connected') delete next[connectorId];
}
return pruneConnectorAuthorizationPending(next, nowMs);
}
export function clearConnectorAuthorizationPending(
pending: ConnectorAuthorizationPendingState,
connectorId: string,
): ConnectorAuthorizationPendingState {
if (pending[connectorId] === undefined) return pending;
const next = { ...pending };
delete next[connectorId];
return next;
}
export function getConnectorDisplayToolCount(connector: ConnectorDetail): number {
return connector.toolCount ?? connector.tools.length;
}
export function hasLoadedAllAdvertisedConnectorTools(connector: ConnectorDetail): boolean {
if (connector.toolsNextCursor) return false;
if (connector.toolCount === undefined) return connector.tools.length > 0;
return connector.tools.length >= connector.toolCount;
}
function mergeConnectorTools(current: ConnectorDetail['tools'], incoming: ConnectorDetail['tools']): ConnectorDetail['tools'] {
const seen = new Set<string>();
const merged: ConnectorDetail['tools'] = [];
for (const tool of [...current, ...incoming]) {
if (seen.has(tool.name)) continue;
seen.add(tool.name);
merged.push(tool);
}
return merged;
}
export function mergeConnectorToolPreview(current: ConnectorDetail, next: ConnectorDetail, append: boolean): ConnectorDetail {
const merged: ConnectorDetail = {
...current,
...next,
tools: append ? mergeConnectorTools(current.tools, next.tools) : next.tools,
toolCount: next.toolCount ?? current.toolCount,
toolsHasMore: next.toolsHasMore ?? false,
featuredToolNames: next.featuredToolNames ?? current.featuredToolNames,
};
if (next.toolsNextCursor !== undefined) return { ...merged, toolsNextCursor: next.toolsNextCursor };
const { toolsNextCursor: _toolsNextCursor, ...withoutCursor } = merged;
return withoutCursor;
}
export function mergeConnectorActionResult(current: ConnectorDetail, next: ConnectorDetail): ConnectorDetail {
return {
...current,
...next,
tools: next.tools.length > 0 ? next.tools : current.tools,
toolCount: next.toolCount ?? current.toolCount,
featuredToolNames: next.featuredToolNames ?? current.featuredToolNames,
};
}
function applyConnectorStatuses(
current: ConnectorDetail[],
statuses: ConnectorStatusResponse['statuses'],
@ -392,17 +538,37 @@ export function ConnectorsBrowser({
connectorId: string;
action: 'connect' | 'disconnect';
} | null>(null);
const [connectorAuthorizationPending, setConnectorAuthorizationPending] = useState<ConnectorAuthorizationPendingState>(() => loadConnectorAuthorizationPending());
const [connectorAuthorizationCancelFailed, setConnectorAuthorizationCancelFailed] = useState<Record<string, boolean>>({});
const [detailConnectorId, setDetailConnectorId] = useState<string | null>(null);
const [toolPreviewLoadingIds, setToolPreviewLoadingIds] = useState<Record<string, boolean>>({});
const [toolPreviewFetchedIds, setToolPreviewFetchedIds] = useState<Record<string, boolean>>({});
const [toolPreviewFailedIds, setToolPreviewFailedIds] = useState<Record<string, string>>({});
const [filter, setFilter] = useState('');
const [selectedProvider, setSelectedProvider] = useState<string>(DEFAULT_PROVIDER_TAB_ID);
const searchInputRef = useRef<HTMLInputElement | null>(null);
const logoTheme = useResolvedTheme();
const toolPreviewRetryToken = `${composioConfigured ? 'configured' : 'unconfigured'}:${String(catalogRefreshKey)}`;
const reloadConnectorStatuses = useCallback(async () => {
const statuses = await fetchConnectorStatuses();
setConnectors((curr) => applyConnectorStatuses(curr, statuses));
setConnectorAuthorizationPending((curr) => updateConnectorAuthorizationPendingFromStatuses(curr, statuses));
}, []);
useEffect(() => {
saveConnectorAuthorizationPending(connectorAuthorizationPending);
}, [connectorAuthorizationPending]);
useEffect(() => {
if (Object.keys(connectorAuthorizationPending).length === 0) return;
const interval = window.setInterval(() => {
setConnectorAuthorizationPending((curr) => pruneConnectorAuthorizationPending(curr));
void reloadConnectorStatuses();
}, CONNECTOR_AUTH_PENDING_POLL_MS);
return () => window.clearInterval(interval);
}, [connectorAuthorizationPending, reloadConnectorStatuses]);
// Initial catalog fetch — always loads the lightweight registry payload so
// already-configured connectors render immediately.
useEffect(() => {
@ -502,7 +668,9 @@ export function ConnectorsBrowser({
function updateConnector(next: ConnectorDetail | null) {
if (!next) return;
setConnectors((curr) => curr.map((connector) => (connector.id === next.id ? next : connector)));
setConnectors((curr) => curr.map((connector) => (
connector.id === next.id ? mergeConnectorActionResult(connector, next) : connector
)));
}
async function runConnectorAction(connectorId: string, action: 'connect' | 'disconnect') {
@ -510,9 +678,24 @@ export function ConnectorsBrowser({
setPendingConnectorAction({ connectorId, action });
try {
if (action === 'connect') {
setConnectorAuthorizationCancelFailed((curr) => {
if (curr[connectorId] === undefined) return curr;
const next = { ...curr };
delete next[connectorId];
return next;
});
const result = await connectConnector(connectorId);
updateConnector(result.connector);
if (result.connector && !result.error) {
setConnectorAuthorizationPending((curr) => updateConnectorAuthorizationPendingFromConnectResponse(curr, {
connector: result.connector!,
...(result.auth === undefined ? {} : { auth: result.auth }),
}));
} else {
setConnectorAuthorizationPending((curr) => clearConnectorAuthorizationPending(curr, connectorId));
}
} else {
setConnectorAuthorizationPending((curr) => clearConnectorAuthorizationPending(curr, connectorId));
updateConnector(await disconnectConnector(connectorId));
}
} finally {
@ -525,6 +708,73 @@ export function ConnectorsBrowser({
[detailConnectorId, connectors],
);
async function hydrateToolPreview(connectorId: string, cursor?: string) {
if (!composioConfigured) return;
if (toolPreviewLoadingIds[connectorId]) return;
setToolPreviewLoadingIds((curr) => ({ ...curr, [connectorId]: true }));
try {
const next = await fetchConnectorDetail(connectorId, {
hydrateTools: true,
toolsLimit: CONNECTOR_TOOL_PREVIEW_LIMIT,
...(cursor === undefined ? {} : { toolsCursor: cursor }),
});
if (next) {
setConnectors((curr) => curr.map((connector) => (
connector.id === next.id ? mergeConnectorToolPreview(connector, next, cursor !== undefined) : connector
)));
setToolPreviewFetchedIds((curr) => ({ ...curr, [connectorId]: true }));
setToolPreviewFailedIds((curr) => {
if (curr[connectorId] === undefined) return curr;
const nextFailed = { ...curr };
delete nextFailed[connectorId];
return nextFailed;
});
} else {
setToolPreviewFailedIds((curr) => ({ ...curr, [connectorId]: toolPreviewRetryToken }));
}
} catch {
setToolPreviewFailedIds((curr) => ({ ...curr, [connectorId]: toolPreviewRetryToken }));
} finally {
setToolPreviewLoadingIds((curr) => ({ ...curr, [connectorId]: false }));
}
}
useEffect(() => {
if (!detailConnector) return;
if (!composioConfigured) return;
if (hasLoadedAllAdvertisedConnectorTools(detailConnector)) return;
if (toolPreviewFetchedIds[detailConnector.id]) return;
if (toolPreviewFailedIds[detailConnector.id] === toolPreviewRetryToken) return;
if (toolPreviewLoadingIds[detailConnector.id]) return;
void hydrateToolPreview(detailConnector.id);
}, [composioConfigured, detailConnector, toolPreviewFailedIds, toolPreviewFetchedIds, toolPreviewLoadingIds, toolPreviewRetryToken]);
function openConnectorDetails(connectorId: string) {
setToolPreviewFailedIds((curr) => {
if (curr[connectorId] === undefined) return curr;
const next = { ...curr };
delete next[connectorId];
return next;
});
setDetailConnectorId(connectorId);
}
async function cancelConnectorAuthorization(connectorId: string) {
const connector = await cancelConnectorAuthorizationRequest(connectorId);
if (connector) {
updateConnector(connector);
setConnectorAuthorizationCancelFailed((curr) => {
if (curr[connectorId] === undefined) return curr;
const next = { ...curr };
delete next[connectorId];
return next;
});
setConnectorAuthorizationPending((curr) => clearConnectorAuthorizationPending(curr, connectorId));
return;
}
setConnectorAuthorizationCancelFailed((curr) => ({ ...curr, [connectorId]: true }));
}
return (
<div className="tab-panel connectors-panel connectors-panel-embedded">
<div className="tab-panel-toolbar">
@ -639,12 +889,15 @@ export function ConnectorsBrowser({
? pendingConnectorAction.action
: null
}
authorizationPending={connectorAuthorizationPending[connector.id]}
authorizationCancelFailed={connectorAuthorizationCancelFailed[connector.id] === true}
toolsLoading={toolsLoading}
toolsLoaded={toolsLoaded}
logoTheme={logoTheme}
onConnect={(connectorId) => runConnectorAction(connectorId, 'connect')}
onDisconnect={(connectorId) => runConnectorAction(connectorId, 'disconnect')}
onOpenDetails={(connectorId) => setDetailConnectorId(connectorId)}
onCancelAuthorization={cancelConnectorAuthorization}
onOpenDetails={openConnectorDetails}
/>
))}
</div>
@ -676,12 +929,21 @@ export function ConnectorsBrowser({
? pendingConnectorAction.action
: null
}
authorizationPending={connectorAuthorizationPending[detailConnector.id]}
authorizationCancelFailed={connectorAuthorizationCancelFailed[detailConnector.id] === true}
toolsLoading={toolsLoading}
toolsLoaded={toolsLoaded}
toolsPreviewLoading={Boolean(toolPreviewLoadingIds[detailConnector.id])}
toolsLoaded={
Boolean(toolPreviewFetchedIds[detailConnector.id])
|| toolPreviewFailedIds[detailConnector.id] === toolPreviewRetryToken
|| hasLoadedAllAdvertisedConnectorTools(detailConnector)
}
logoTheme={logoTheme}
onClose={() => setDetailConnectorId(null)}
onConnect={(connectorId) => runConnectorAction(connectorId, 'connect')}
onDisconnect={(connectorId) => runConnectorAction(connectorId, 'disconnect')}
onCancelAuthorization={cancelConnectorAuthorization}
onLoadMoreTools={(connectorId, cursor) => hydrateToolPreview(connectorId, cursor)}
/>
) : null}
</div>
@ -692,32 +954,39 @@ function ConnectorCard({
connector,
disabled = false,
pendingAction,
authorizationPending,
authorizationCancelFailed,
toolsLoading: _toolsLoading,
toolsLoaded,
logoTheme,
onConnect,
onDisconnect,
onCancelAuthorization,
onOpenDetails,
}: {
connector: ConnectorDetail;
disabled?: boolean;
pendingAction: 'connect' | 'disconnect' | null;
authorizationPending?: ConnectorAuthorizationPending;
authorizationCancelFailed: boolean;
toolsLoading: boolean;
toolsLoaded: boolean;
logoTheme: 'light' | 'dark';
onConnect: (connectorId: string) => Promise<void> | void;
onDisconnect: (connectorId: string) => Promise<void> | void;
onCancelAuthorization: (connectorId: string) => void;
onOpenDetails: (connectorId: string) => void;
}) {
const t = useT();
const isConnecting = pendingAction === 'connect';
const isDisconnecting = pendingAction === 'disconnect';
const isPending = pendingAction !== null;
const isConnected = connector.status === 'connected';
const isAuthorizationPending = !isConnected && authorizationPending !== undefined;
const isPending = pendingAction !== null || isAuthorizationPending;
const canConnect = !disabled && !isPending && connector.status === 'available';
const canDisconnect = !disabled && !isPending && isConnected;
const toolCount = connector.tools.length;
const showToolsBadge = toolsLoaded && toolCount > 0;
const toolCount = getConnectorDisplayToolCount(connector);
const showToolsBadge = connector.toolCount !== undefined || connector.tools.length > 0 || toolsLoaded;
const toolsBadgeLabel = formatToolsBadge(toolCount, t);
const categoryLabel = connectorCategoryLabel(connector.category, t);
@ -768,6 +1037,13 @@ function ConnectorCard({
title={statusLabel(connector.status, t)}
role="img"
/>
) : isAuthorizationPending ? (
<span
className="connector-status-dot connector-card-title-dot status-pending"
aria-label={t('connectors.authorizationPending')}
title={t('connectors.authorizationPending')}
role="img"
/>
) : null}
</h3>
{/* Two-row meta block. Splitting category and tools-badge onto
@ -816,11 +1092,11 @@ function ConnectorCard({
) : (
<button
type="button"
className={`icon-only connector-action is-connect${isConnecting ? ' is-loading' : ''}`}
className={`icon-only connector-action is-connect${isConnecting || isAuthorizationPending ? ' is-loading' : ''}`}
disabled={!canConnect}
aria-busy={isConnecting || undefined}
aria-label={t('connectors.connect')}
title={t('connectors.connect')}
aria-busy={isConnecting || isAuthorizationPending || undefined}
aria-label={isAuthorizationPending ? t('connectors.authorizationPending') : t('connectors.connect')}
title={isAuthorizationPending ? t('connectors.authorizationPendingHint') : t('connectors.connect')}
tabIndex={disabled ? -1 : undefined}
onMouseDown={stop}
onKeyDown={stop}
@ -829,9 +1105,25 @@ function ConnectorCard({
onConnect(connector.id);
}}
>
<Icon name={isConnecting ? 'spinner' : 'plus'} size={12} />
<Icon name={isConnecting || isAuthorizationPending ? 'spinner' : 'plus'} size={12} />
</button>
)}
{isAuthorizationPending ? (
<button
type="button"
className="icon-only connector-action is-cancel-authorization"
aria-label={t('connectors.cancelAuthorization')}
title={t('connectors.cancelAuthorization')}
onMouseDown={stop}
onKeyDown={stop}
onClick={(e) => {
stop(e);
onCancelAuthorization(connector.id);
}}
>
<Icon name="close" size={12} />
</button>
) : null}
{connector.status === 'error' || connector.status === 'disabled' ? (
<span className={`connector-status-pill status-${connector.status}`}>
{statusLabel(connector.status, t)}
@ -839,6 +1131,11 @@ function ConnectorCard({
) : null}
</div>
</div>
{authorizationCancelFailed ? (
<p className="connector-authorization-hint connector-authorization-error" role="alert">
{AUTHORIZATION_CANCEL_FAILED_MESSAGE}
</p>
) : null}
</article>
);
}
@ -872,34 +1169,47 @@ function ConnectorDetailDrawer({
connector,
disabled,
pendingAction,
authorizationPending,
authorizationCancelFailed,
toolsLoading,
toolsPreviewLoading,
toolsLoaded,
logoTheme,
onClose,
onConnect,
onDisconnect,
onCancelAuthorization,
onLoadMoreTools,
}: {
connector: ConnectorDetail;
disabled: boolean;
pendingAction: 'connect' | 'disconnect' | null;
authorizationPending?: ConnectorAuthorizationPending;
authorizationCancelFailed: boolean;
toolsLoading: boolean;
toolsPreviewLoading: boolean;
toolsLoaded: boolean;
logoTheme: 'light' | 'dark';
onClose: () => void;
onConnect: (connectorId: string) => Promise<void> | void;
onDisconnect: (connectorId: string) => Promise<void> | void;
onCancelAuthorization: (connectorId: string) => void;
onLoadMoreTools: (connectorId: string, cursor: string) => Promise<void> | void;
}) {
const t = useT();
const isConnected = connector.status === 'connected';
const isConnecting = pendingAction === 'connect';
const isDisconnecting = pendingAction === 'disconnect';
const isPending = pendingAction !== null;
const isAuthorizationPending = !isConnected && authorizationPending !== undefined;
const isPending = pendingAction !== null || isAuthorizationPending;
const canConnect = !disabled && !isPending && connector.status === 'available';
const canDisconnect = !disabled && !isPending && isConnected;
const accountLabel = getDisplayableConnectorAccountLabel(connector);
const toolCount = connector.tools.length;
const isLoadingTools = !toolsLoaded || (toolsLoading && toolCount === 0);
const showToolsBadge = toolsLoaded && toolCount > 0;
const actualToolCount = connector.tools.length;
const toolCount = getConnectorDisplayToolCount(connector);
const isLoadingTools = toolsPreviewLoading || !toolsLoaded || (toolsLoading && actualToolCount === 0);
const toolDetailsUnavailable = toolsLoaded && actualToolCount === 0 && toolCount > 0;
const showToolsBadge = connector.toolCount !== undefined || actualToolCount > 0 || toolsLoaded;
const closeBtnRef = useRef<HTMLButtonElement | null>(null);
const categoryLabel = connectorCategoryLabel(connector.category, t);
@ -920,7 +1230,7 @@ function ConnectorDetailDrawer({
};
}, [onClose]);
const statusTone = connector.status;
const statusTone = isAuthorizationPending ? 'pending' : connector.status;
return (
<div
@ -950,7 +1260,7 @@ function ConnectorDetailDrawer({
<div className="connector-drawer-status">
<span className={`connector-status-pill status-${statusTone}`}>
<span className="connector-status-dot" aria-hidden />
{statusLabel(connector.status, t)}
{isAuthorizationPending ? t('connectors.authorizationPending') : statusLabel(connector.status, t)}
</span>
{showToolsBadge ? (
<span className="connector-tools-badge is-ready" title={formatToolsBadge(toolCount, t)}>
@ -977,8 +1287,18 @@ function ConnectorDetailDrawer({
<section className="connector-drawer-section">
<h3 className="connector-drawer-section-title">{t('connectors.aboutLabel')}</h3>
<p className="connector-drawer-description">{connector.description}</p>
{isAuthorizationPending ? (
<p className="connector-authorization-hint" role="status">
{t('connectors.authorizationPendingHint')}
</p>
) : null}
</section>
) : null}
{authorizationCancelFailed ? (
<p className="connector-authorization-hint connector-authorization-error" role="alert">
{AUTHORIZATION_CANCEL_FAILED_MESSAGE}
</p>
) : null}
<section className="connector-drawer-section">
<h3 className="connector-drawer-section-title">{t('connectors.detailsLabel')}</h3>
@ -1016,28 +1336,43 @@ function ConnectorDetailDrawer({
</h3>
{isLoadingTools ? (
<p className="connector-drawer-empty"><Icon name="spinner" size={12} /> {t('connectors.toolsLoading')}</p>
) : toolCount === 0 ? (
) : toolDetailsUnavailable ? (
<p className="connector-drawer-empty">{t('connectors.toolDetailsUnavailable', { n: toolCount })}</p>
) : actualToolCount === 0 ? (
<p className="connector-drawer-empty">{t('connectors.noToolsAvailable')}</p>
) : (
<ul className="connector-drawer-tools">
{connector.tools.map((tool) => (
<li key={tool.name} className="connector-drawer-tool">
<div className="connector-drawer-tool-head">
<span className="connector-drawer-tool-title">{tool.title || tool.name}</span>
<span
className={`connector-drawer-tool-badge side-${tool.safety.sideEffect}`}
title={tool.safety.reason}
>
{tool.safety.sideEffect}
</span>
</div>
{tool.description ? (
<p className="connector-drawer-tool-desc">{tool.description}</p>
) : null}
<code className="connector-drawer-tool-name">{tool.name}</code>
</li>
))}
</ul>
<>
<ul className="connector-drawer-tools">
{connector.tools.map((tool) => (
<li key={tool.name} className="connector-drawer-tool">
<div className="connector-drawer-tool-head">
<span className="connector-drawer-tool-title">{tool.title || tool.name}</span>
<span
className={`connector-drawer-tool-badge side-${tool.safety.sideEffect}`}
title={tool.safety.reason}
>
{tool.safety.sideEffect}
</span>
</div>
{tool.description ? (
<p className="connector-drawer-tool-desc">{tool.description}</p>
) : null}
<code className="connector-drawer-tool-name">{tool.name}</code>
</li>
))}
</ul>
{connector.toolsNextCursor ? (
<button
type="button"
className="ghost connector-drawer-load-more"
disabled={toolsPreviewLoading}
onClick={() => onLoadMoreTools(connector.id, connector.toolsNextCursor!)}
>
{toolsPreviewLoading ? <Icon name="spinner" size={12} /> : null}
<span>{t('connectors.loadMoreTools')}</span>
</button>
) : null}
</>
)}
</section>
</div>
@ -1057,15 +1392,24 @@ function ConnectorDetailDrawer({
) : (
<button
type="button"
className={`primary connector-action is-connect${isConnecting ? ' is-loading' : ''}`}
className={`primary connector-action is-connect${isConnecting || isAuthorizationPending ? ' is-loading' : ''}`}
disabled={!canConnect}
aria-busy={isConnecting || undefined}
aria-busy={isConnecting || isAuthorizationPending || undefined}
onClick={() => onConnect(connector.id)}
>
{isConnecting ? <Icon name="spinner" size={12} /> : null}
<span>{t('connectors.connect')}</span>
{isConnecting || isAuthorizationPending ? <Icon name="spinner" size={12} /> : null}
<span>{isAuthorizationPending ? t('connectors.authorizationPending') : t('connectors.connect')}</span>
</button>
)}
{isAuthorizationPending ? (
<button
type="button"
className="ghost connector-action is-cancel-authorization"
onClick={() => onCancelAuthorization(connector.id)}
>
<span>{t('connectors.cancelAuthorization')}</span>
</button>
) : null}
</footer>
</aside>
</div>

View file

@ -0,0 +1,215 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import type { Dispatch, SetStateAction } from 'react';
import { useT } from '../i18n';
import type { AppConfig } from '../types';
import type { DesignSystemSummary } from '@open-design/contracts';
import {
fetchDesignSystem,
fetchDesignSystems,
} from '../providers/registry';
// Sibling Settings section that hosts the design-systems registry.
// Lifted out of the previous LibrarySection so each surface (functional
// skills vs. design systems) gets its own dedicated nav entry instead of
// sharing a sub-tab toggle. See specs/current/skills-and-design-templates.md.
interface Props {
cfg: AppConfig;
setCfg: Dispatch<SetStateAction<AppConfig>>;
}
export function DesignSystemsSection({ cfg, setCfg }: Props) {
const t = useT();
const [designSystems, setDesignSystems] = useState<DesignSystemSummary[]>([]);
const [search, setSearch] = useState('');
const [categoryFilter, setCategoryFilter] = useState('All');
const [previewId, setPreviewId] = useState<string | null>(null);
const [previewBody, setPreviewBody] = useState<string | null>(null);
const [previewLoading, setPreviewLoading] = useState(false);
useEffect(() => {
fetchDesignSystems().then(setDesignSystems);
}, []);
const disabledDS = useMemo(
() => new Set(cfg.disabledDesignSystems ?? []),
[cfg.disabledDesignSystems],
);
const categories = useMemo(() => {
const cats = new Set(designSystems.map((d) => d.category));
return ['All', ...Array.from(cats).sort()];
}, [designSystems]);
const filtered = useMemo(() => {
const q = search.toLowerCase();
return designSystems.filter((d) => {
if (categoryFilter !== 'All' && d.category !== categoryFilter) return false;
if (
q &&
!d.title.toLowerCase().includes(q) &&
!d.summary.toLowerCase().includes(q)
)
return false;
return true;
});
}, [designSystems, categoryFilter, search]);
const grouped = useMemo(() => {
const groups = new Map<string, DesignSystemSummary[]>();
for (const d of filtered) {
const list = groups.get(d.category) ?? [];
list.push(d);
groups.set(d.category, list);
}
return groups;
}, [filtered]);
const openPreview = useCallback(
async (id: string) => {
if (previewId === id) {
setPreviewId(null);
setPreviewBody(null);
return;
}
setPreviewId(id);
setPreviewBody(null);
setPreviewLoading(true);
try {
const detail = await fetchDesignSystem(id);
setPreviewId((cur) => {
if (cur === id) setPreviewBody(detail?.body ?? null);
return cur;
});
} catch {
setPreviewId((cur) => {
if (cur === id) setPreviewBody(null);
return cur;
});
} finally {
setPreviewId((cur) => {
if (cur === id) setPreviewLoading(false);
return cur;
});
}
},
[previewId],
);
function toggleDSDisabled(id: string, enabled: boolean) {
setCfg((c) => {
const set = new Set(c.disabledDesignSystems ?? []);
if (enabled) set.delete(id);
else set.add(id);
return { ...c, disabledDesignSystems: [...set] };
});
}
return (
<section className="settings-section settings-design-systems">
<div className="section-head">
<div>
<h3>{t('settings.designSystems')}</h3>
<p className="hint">{t('settings.designSystemsHint')}</p>
</div>
</div>
<div className="library-toolbar">
<input
type="search"
className="library-search"
placeholder={t('settings.librarySearch')}
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<div className="library-filters">
{categories.map((cat) => {
const count =
cat === 'All'
? designSystems.length
: designSystems.filter((d) => d.category === cat).length;
return (
<button
key={cat}
type="button"
className={`filter-pill${categoryFilter === cat ? ' active' : ''}`}
onClick={() => setCategoryFilter(cat)}
>
{cat}
<span className="filter-pill-count">{count}</span>
</button>
);
})}
</div>
</div>
<div className="library-content">
{filtered.length === 0 ? (
<p className="library-empty">{t('settings.libraryNoResults')}</p>
) : (
<>
{Array.from(grouped.entries()).map(([category, items]) => (
<div key={category} className="library-group">
<h4 className="library-group-title">
{category}{' '}
<span className="library-group-count">{items.length}</span>
</h4>
<div className="ds-grid">
{items.map((ds) => (
<div
key={ds.id}
className={`library-ds-card${
disabledDS.has(ds.id) ? ' disabled' : ''
}`}
>
<div
className="library-ds-card-content"
onClick={() => openPreview(ds.id)}
>
{ds.swatches && ds.swatches.length > 0 && (
<div className="library-ds-swatches">
{ds.swatches.slice(0, 4).map((c, i) => (
<span
key={i}
className="library-ds-swatch"
style={{ backgroundColor: c }}
/>
))}
</div>
)}
<div className="library-ds-title">{ds.title}</div>
<div className="library-ds-summary">{ds.summary}</div>
</div>
<label
className="toggle-switch toggle-switch-sm"
title={t('settings.libraryToggleLabel')}
>
<input
type="checkbox"
checked={!disabledDS.has(ds.id)}
onChange={(e) =>
toggleDSDisabled(ds.id, e.target.checked)
}
/>
<span className="toggle-slider" />
</label>
</div>
))}
</div>
</div>
))}
{previewId && filtered.some((d) => d.id === previewId) && (
<div className="library-preview">
{previewLoading ? (
<p>{t('settings.libraryLoading')}</p>
) : previewBody ? (
<pre className="library-preview-body">{previewBody}</pre>
) : null}
</div>
)}
</>
)}
</div>
</section>
);
}

View file

@ -35,10 +35,17 @@ import { PromptTemplatePreviewModal } from './PromptTemplatePreviewModal';
import { PromptTemplatesTab } from './PromptTemplatesTab';
import { apiProtocolLabel } from '../utils/apiProtocol';
type TopTab = 'designs' | 'examples' | 'design-systems' | 'image-templates' | 'video-templates';
type TopTab = 'designs' | 'templates' | 'design-systems' | 'image-templates' | 'video-templates';
interface Props {
// Union of functional skills + design templates — used for id-based
// lookups (DesignsTab project chips, NewProjectPanel skill picker).
// The Templates gallery itself reads `designTemplates` instead so it
// doesn't accidentally show functional skills as renderable cards.
skills: SkillSummary[];
// Design templates only. Sourced from /api/design-templates. See
// specs/current/skills-and-design-templates.md.
designTemplates: SkillSummary[];
designSystems: DesignSystemSummary[];
projects: Project[];
templates: ProjectTemplate[];
@ -214,6 +221,7 @@ function loadPetRailHidden(): boolean {
export function EntryView({
skills,
designTemplates,
designSystems,
projects,
templates,
@ -554,7 +562,7 @@ export function EntryView({
<div className="entry-header">
<div className="entry-tabs" role="tablist">
<TopTabButton current={topTab} value="designs" label={t('entry.tabDesigns')} onClick={setTopTab} />
<TopTabButton current={topTab} value="examples" label={t('entry.tabExamples')} onClick={setTopTab} />
<TopTabButton current={topTab} value="templates" label={t('entry.tabTemplates')} onClick={setTopTab} />
<TopTabButton
current={topTab}
value="design-systems"
@ -594,11 +602,14 @@ export function EntryView({
/>
)
) : null}
{topTab === 'examples' ? (
{topTab === 'templates' ? (
skillsLoading ? (
<CenteredLoader label={t('common.loading')} />
) : (
<ExamplesTab skills={skills} onUsePrompt={usePromptFromSkill} />
<ExamplesTab
skills={designTemplates}
onUsePrompt={usePromptFromSkill}
/>
)
) : null}
{topTab === 'design-systems' ? (

View file

@ -2882,6 +2882,8 @@ function HtmlViewer({
const [boardMode, setBoardMode] = useState(false);
const [boardTool, setBoardTool] = useState<BoardTool>('inspect');
const [inspectMode, setInspectMode] = useState(false);
// for hint managing hint box state
const [openHintBox, setOpenHintBox] = useState(true);
const [manualEditMode, setManualEditMode] = useState(false);
const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([]);
const [selectedManualEditTarget, setSelectedManualEditTarget] = useState<ManualEditTarget | null>(null);
@ -4232,6 +4234,7 @@ function HtmlViewer({
setBoardMode(false);
clearBoardComposer();
setManualEditMode(false);
setOpenHintBox(true);
}
return next;
});
@ -4617,10 +4620,20 @@ function HtmlViewer({
error={inspectError}
/>
) : null}
{inspectMode && !activeInspectTarget ? (
<div className="inspect-empty-hint" data-testid="inspect-empty-hint">
{inspectMode && openHintBox && !activeInspectTarget ? (
<div className="inspect-empty-hint-container">
<div className="inspect-empty-hint" data-testid="inspect-empty-hint">
Click any element with <code>data-od-id</code> to tune its style.
</div>
<button
type="button"
title="Close Inspect Hint"
aria-label="Close Inspect Hint"
onClick={() => setOpenHintBox(false)}
className="orbit-artifact-ghost">
<Icon className='' name='close' size={12} />
</button>
</div>
) : null}
</div>
) : (

View file

@ -1,556 +0,0 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import type { Dispatch, SetStateAction } from 'react';
import { useT } from '../i18n';
import { Icon } from './Icon';
import type { AppConfig } from '../types';
import type { SkillSummary, DesignSystemSummary } from '@open-design/contracts';
import {
deleteSkill,
fetchSkills,
fetchDesignSystems,
fetchSkill,
fetchDesignSystem,
importSkill,
} from '../providers/registry';
type Tab = 'skills' | 'design-systems';
interface Props {
cfg: AppConfig;
setCfg: Dispatch<SetStateAction<AppConfig>>;
}
const MODES = [
'prototype',
'deck',
'template',
'design-system',
'image',
'video',
'audio',
] as const;
export function LibrarySection({ cfg, setCfg }: Props) {
const t = useT();
const [tab, setTab] = useState<Tab>('skills');
const [search, setSearch] = useState('');
const [modeFilter, setModeFilter] = useState('all');
const [categoryFilter, setCategoryFilter] = useState('All');
const [skills, setSkills] = useState<SkillSummary[]>([]);
const [designSystems, setDesignSystems] = useState<DesignSystemSummary[]>([]);
const [previewId, setPreviewId] = useState<string | null>(null);
const [previewBody, setPreviewBody] = useState<string | null>(null);
const [previewLoading, setPreviewLoading] = useState(false);
// Inline "Import skill" form state. The form is intentionally minimal —
// `name` is the SKILL.md `name` (and the slug we write under user-skills/),
// `body` is everything below the front-matter. Triggers get auto-split
// on commas / newlines.
const [importOpen, setImportOpen] = useState(false);
const [importName, setImportName] = useState('');
const [importDescription, setImportDescription] = useState('');
const [importTriggers, setImportTriggers] = useState('');
const [importBody, setImportBody] = useState('');
const [importing, setImporting] = useState(false);
const [importError, setImportError] = useState<string | null>(null);
const refreshSkills = useCallback(async () => {
const list = await fetchSkills();
setSkills(list);
return list;
}, []);
useEffect(() => {
void refreshSkills();
fetchDesignSystems().then(setDesignSystems);
}, [refreshSkills]);
const categories = useMemo(() => {
const cats = new Set(designSystems.map((d) => d.category));
return ['All', ...Array.from(cats).sort()];
}, [designSystems]);
const disabledSkills = useMemo(
() => new Set(cfg.disabledSkills ?? []),
[cfg.disabledSkills],
);
const disabledDS = useMemo(
() => new Set(cfg.disabledDesignSystems ?? []),
[cfg.disabledDesignSystems],
);
const filteredSkills = useMemo(() => {
const q = search.toLowerCase();
return skills.filter((s) => {
if (modeFilter !== 'all' && s.mode !== modeFilter) return false;
if (q && !s.name.toLowerCase().includes(q) && !s.description.toLowerCase().includes(q))
return false;
return true;
});
}, [skills, modeFilter, search]);
const filteredDS = useMemo(() => {
const q = search.toLowerCase();
return designSystems.filter((d) => {
if (categoryFilter !== 'All' && d.category !== categoryFilter) return false;
if (q && !d.title.toLowerCase().includes(q) && !d.summary.toLowerCase().includes(q))
return false;
return true;
});
}, [designSystems, categoryFilter, search]);
const groupedSkills = useMemo(() => {
const groups = new Map<string, SkillSummary[]>();
for (const s of filteredSkills) {
const list = groups.get(s.mode) ?? [];
list.push(s);
groups.set(s.mode, list);
}
return groups;
}, [filteredSkills]);
const groupedDS = useMemo(() => {
const groups = new Map<string, DesignSystemSummary[]>();
for (const d of filteredDS) {
const list = groups.get(d.category) ?? [];
list.push(d);
groups.set(d.category, list);
}
return groups;
}, [filteredDS]);
const openPreview = useCallback(
async (id: string) => {
if (previewId === id) {
setPreviewId(null);
setPreviewBody(null);
return;
}
setPreviewId(id);
setPreviewBody(null);
setPreviewLoading(true);
try {
const detail =
tab === 'skills'
? await fetchSkill(id)
: await fetchDesignSystem(id);
setPreviewId((cur) => {
if (cur === id) setPreviewBody(detail?.body ?? null);
return cur;
});
} catch {
setPreviewId((cur) => {
if (cur === id) setPreviewBody(null);
return cur;
});
} finally {
setPreviewId((cur) => {
if (cur === id) setPreviewLoading(false);
return cur;
});
}
},
[previewId, tab],
);
function resetImportForm() {
setImportName('');
setImportDescription('');
setImportTriggers('');
setImportBody('');
setImportError(null);
}
async function handleImportSubmit() {
if (importing) return;
const name = importName.trim();
const body = importBody.trim();
if (!name) {
setImportError('Skill name is required.');
return;
}
if (!body) {
setImportError('Skill body is required.');
return;
}
const triggers = importTriggers
.split(/[,\n]/)
.map((t) => t.trim())
.filter(Boolean);
setImporting(true);
setImportError(null);
const result = await importSkill({
name,
description: importDescription.trim() || undefined,
body,
triggers,
});
setImporting(false);
if ('error' in result) {
setImportError(result.error.message);
return;
}
await refreshSkills();
resetImportForm();
setImportOpen(false);
}
async function handleDeleteSkill(id: string) {
if (typeof window !== 'undefined') {
const ok = window.confirm(`Delete skill "${id}"? This cannot be undone.`);
if (!ok) return;
}
const result = await deleteSkill(id);
if ('error' in result) {
setImportError(result.error.message);
return;
}
await refreshSkills();
setCfg((c) => {
const set = new Set(c.disabledSkills ?? []);
set.delete(id);
return { ...c, disabledSkills: [...set] };
});
if (previewId === id) {
setPreviewId(null);
setPreviewBody(null);
}
}
function toggleSkillDisabled(id: string, disabled: boolean) {
setCfg((c) => {
const set = new Set(c.disabledSkills ?? []);
if (disabled) set.add(id);
else set.delete(id);
return { ...c, disabledSkills: [...set] };
});
}
function toggleDSDisabled(id: string, disabled: boolean) {
setCfg((c) => {
const set = new Set(c.disabledDesignSystems ?? []);
if (disabled) set.add(id);
else set.delete(id);
return { ...c, disabledDesignSystems: [...set] };
});
}
return (
<section className="settings-section">
<div className="section-head">
<div>
<h3>{t('settings.library')}</h3>
<p className="hint">{t('settings.libraryHint')}</p>
</div>
</div>
<div className="seg-control" role="tablist">
<button
type="button"
role="tab"
className={`seg-btn${tab === 'skills' ? ' active' : ''}`}
onClick={() => {
setTab('skills');
setModeFilter('all');
setCategoryFilter('All');
setSearch('');
setPreviewId(null);
}}
>
<span className="seg-title">
{t('settings.librarySkills')}
<span className="seg-meta">{skills.length}</span>
</span>
</button>
<button
type="button"
role="tab"
className={`seg-btn${tab === 'design-systems' ? ' active' : ''}`}
onClick={() => {
setTab('design-systems');
setModeFilter('all');
setCategoryFilter('All');
setSearch('');
setPreviewId(null);
}}
>
<span className="seg-title">
{t('settings.libraryDesignSystems')}
<span className="seg-meta">{designSystems.length}</span>
</span>
</button>
</div>
<div className="library-toolbar">
<input
type="search"
className="library-search"
placeholder={t('settings.librarySearch')}
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
{tab === 'skills' ? (
<button
type="button"
className={`filter-pill library-import-toggle${importOpen ? ' active' : ''}`}
onClick={() => {
setImportOpen((open) => {
if (open) resetImportForm();
return !open;
});
}}
data-testid="library-import-toggle"
>
<Icon name="upload" size={12} />
<span>Import skill</span>
</button>
) : null}
{tab === 'skills' ? (
<div className="library-filters">
<button
type="button"
className={`filter-pill${modeFilter === 'all' ? ' active' : ''}`}
onClick={() => setModeFilter('all')}
>
{t('settings.libraryAll')}
</button>
{MODES.map((mode) => {
const count = skills.filter((s) => s.mode === mode).length;
if (count === 0) return null;
return (
<button
key={mode}
type="button"
className={`filter-pill${modeFilter === mode ? ' active' : ''}`}
onClick={() => setModeFilter(mode)}
>
{mode}
<span className="filter-pill-count">{count}</span>
</button>
);
})}
</div>
) : (
<div className="library-filters">
{categories.map((cat) => {
const count =
cat === 'All'
? designSystems.length
: designSystems.filter((d) => d.category === cat).length;
return (
<button
key={cat}
type="button"
className={`filter-pill${categoryFilter === cat ? ' active' : ''}`}
onClick={() => setCategoryFilter(cat)}
>
{cat}
<span className="filter-pill-count">{count}</span>
</button>
);
})}
</div>
)}
</div>
{tab === 'skills' && importOpen ? (
<div className="library-import-form" data-testid="library-import-form">
<div className="library-import-row">
<label>
<span>Name</span>
<input
type="text"
value={importName}
onChange={(e) => setImportName(e.target.value)}
placeholder="my-skill"
/>
</label>
<label>
<span>Triggers (comma- or newline-separated)</span>
<input
type="text"
value={importTriggers}
onChange={(e) => setImportTriggers(e.target.value)}
placeholder="search the web, summarize"
/>
</label>
</div>
<label className="library-import-block">
<span>Description</span>
<textarea
rows={2}
value={importDescription}
onChange={(e) => setImportDescription(e.target.value)}
placeholder="What does this skill do? When should the agent reach for it?"
/>
</label>
<label className="library-import-block">
<span>SKILL.md body</span>
<textarea
rows={8}
value={importBody}
onChange={(e) => setImportBody(e.target.value)}
placeholder="# My skill\n\n1. Explain the workflow.\n2. Describe the inputs and outputs."
/>
</label>
{importError ? (
<div className="library-import-error" role="alert">
{importError}
</div>
) : null}
<div className="library-import-actions">
<button
type="button"
className="btn ghost"
onClick={() => {
resetImportForm();
setImportOpen(false);
}}
disabled={importing}
>
Cancel
</button>
<button
type="button"
className="btn primary"
onClick={() => void handleImportSubmit()}
disabled={importing}
>
{importing ? 'Importing…' : 'Import'}
</button>
</div>
</div>
) : null}
<div className="library-content">
{tab === 'skills' ? (
filteredSkills.length === 0 ? (
<p className="library-empty">{t('settings.libraryNoResults')}</p>
) : (
MODES.filter((m) => groupedSkills.has(m)).map((mode) => (
<div key={mode} className="library-group">
<h4 className="library-group-title">
{mode}{' '}
<span className="library-group-count">{groupedSkills.get(mode)!.length}</span>
</h4>
{groupedSkills.get(mode)!.map((skill) => (
<div
key={skill.id}
className={`library-card${disabledSkills.has(skill.id) ? ' disabled' : ''}`}
>
<div className="library-card-info">
<div className="library-card-title-row">
<span className="library-card-name">{skill.name}</span>
<span className="library-card-badge">{skill.previewType}</span>
{skill.source === 'user' ? (
<span
className="library-card-badge library-card-badge-user"
title="User-imported skill"
>
user
</span>
) : null}
</div>
<div className="library-card-desc">{skill.description}</div>
</div>
<button
type="button"
className="library-card-expand"
onClick={() => openPreview(skill.id)}
title={t('settings.libraryPreview')}
>
<Icon
name={previewId === skill.id ? 'close' : 'chevron-right'}
size={14}
/>
</button>
{skill.source === 'user' ? (
<button
type="button"
className="library-card-delete"
onClick={() => void handleDeleteSkill(skill.id)}
title="Delete this user skill"
aria-label={`Delete user skill ${skill.id}`}
>
<Icon name="close" size={12} />
</button>
) : null}
<label className="toggle-switch" title={t('settings.libraryToggleLabel')}>
<input
type="checkbox"
checked={!disabledSkills.has(skill.id)}
onChange={(e) => toggleSkillDisabled(skill.id, !e.target.checked)}
/>
<span className="toggle-slider" />
</label>
{previewId === skill.id && (
<div className="library-preview">
{previewLoading ? (
<p>{t('settings.libraryLoading')}</p>
) : previewBody ? (
<pre className="library-preview-body">{previewBody}</pre>
) : null}
</div>
)}
</div>
))}
</div>
))
)
) : filteredDS.length === 0 ? (
<p className="library-empty">{t('settings.libraryNoResults')}</p>
) : (
<>
{Array.from(groupedDS.entries()).map(([category, items]) => (
<div key={category} className="library-group">
<h4 className="library-group-title">
{category} <span className="library-group-count">{items.length}</span>
</h4>
<div className="ds-grid">
{items.map((ds) => (
<div
key={ds.id}
className={`library-ds-card${disabledDS.has(ds.id) ? ' disabled' : ''}`}
>
<div className="library-ds-card-content" onClick={() => openPreview(ds.id)}>
{ds.swatches && ds.swatches.length > 0 && (
<div className="library-ds-swatches">
{ds.swatches.slice(0, 4).map((c, i) => (
<span
key={i}
className="library-ds-swatch"
style={{ backgroundColor: c }}
/>
))}
</div>
)}
<div className="library-ds-title">{ds.title}</div>
<div className="library-ds-summary">{ds.summary}</div>
</div>
<label className="toggle-switch toggle-switch-sm" title={t('settings.libraryToggleLabel')}>
<input
type="checkbox"
checked={!disabledDS.has(ds.id)}
onChange={(e) => toggleDSDisabled(ds.id, !e.target.checked)}
/>
<span className="toggle-slider" />
</label>
</div>
))}
</div>
</div>
))}
{previewId && filteredDS.some((d) => d.id === previewId) && (
<div className="library-preview">
{previewLoading ? (
<p>{t('settings.libraryLoading')}</p>
) : previewBody ? (
<pre className="library-preview-body">{previewBody}</pre>
) : null}
</div>
)}
</>
)}
</div>
</section>
);
}

View file

@ -1191,7 +1191,7 @@ export function ProjectView({
updateAssistant((prev) => ({
...prev,
endedAt: Date.now(),
runStatus: config.mode === 'api' || prev.runId ? 'succeeded' : prev.runStatus,
runStatus: resolveSucceededRunStatus(prev.runStatus),
}));
if (commentAttachments.length > 0) {
void patchAttachedStatuses(commentAttachments, 'needs_review');
@ -1939,6 +1939,10 @@ function isActiveRunStatus(status: ChatMessage['runStatus']): boolean {
return status === 'queued' || status === 'running';
}
export function resolveSucceededRunStatus(status: ChatMessage['runStatus']): ChatMessage['runStatus'] {
return status === 'failed' || status === 'canceled' ? status : 'succeeded';
}
type BufferedTextUpdates = ReturnType<typeof createBufferedTextUpdates>;
function createBufferedTextUpdates({

View file

@ -1,5 +1,6 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import type { CSSProperties, Dispatch, SetStateAction } from 'react';
import { validateBaseUrl } from '@open-design/contracts/api/connectionTest';
import { LOCALE_LABEL, LOCALES, useI18n } from '../i18n';
import type { Locale } from '../i18n';
import type { Dict } from '../i18n/types';
@ -42,7 +43,8 @@ import { MEDIA_PROVIDERS } from '../media/models';
import type { MediaProvider } from '../media/models';
import { PetSettings } from './pet/PetSettings';
import { McpClientSection } from './McpClientSection';
import { LibrarySection } from './LibrarySection';
import { SkillsSection } from './SkillsSection';
import { DesignSystemsSection } from './DesignSystemsSection';
import { ConnectorsBrowser } from './ConnectorsBrowser';
import {
applyAppearanceToDocument,
@ -68,7 +70,8 @@ export type SettingsSection =
| 'appearance'
| 'notifications'
| 'pet'
| 'library'
| 'skills'
| 'designSystems'
| 'about';
interface Props {
@ -345,95 +348,8 @@ function applyApiProtocolConfig(
export function isValidApiBaseUrl(value: string): boolean {
const trimmed = value.trim();
if (!/^https?:\/\//i.test(trimmed)) return false;
try {
const url = new URL(trimmed);
const hostname = url.hostname.toLowerCase();
return (
(url.protocol === 'http:' || url.protocol === 'https:') &&
Boolean(url.hostname) &&
(isLoopbackApiHost(hostname) || !isBlockedInternalApiHost(hostname))
);
} catch {
return false;
}
}
function normalizeBracketedIpv6(hostname: string): string {
return hostname.startsWith('[') && hostname.endsWith(']')
? hostname.slice(1, -1).toLowerCase()
: hostname.toLowerCase();
}
function parseIpv4(hostname: string): [number, number, number, number] | null {
const parts = hostname.split('.');
if (parts.length !== 4) return null;
const parsed = parts.map((part) => {
if (!/^\d{1,3}$/.test(part)) return null;
const value = Number(part);
return value >= 0 && value <= 255 ? value : null;
});
if (parsed.some((part) => part === null)) return null;
return parsed as [number, number, number, number];
}
function isLoopbackIpv4(hostname: string): boolean {
const parts = parseIpv4(hostname);
return Boolean(parts && parts[0] === 127);
}
function isPrivateIpv4(hostname: string): boolean {
const parts = parseIpv4(hostname);
if (!parts) return false;
const [a, b] = parts;
return (
(a === 169 && b === 254) ||
a === 10 ||
(a === 192 && b === 168) ||
(a === 172 && b >= 16 && b <= 31)
);
}
function ipv4MappedToDotted(hostname: string): string | null {
const host = normalizeBracketedIpv6(hostname);
const mapped = /^::ffff:(.+)$/i.exec(host)?.[1];
if (!mapped) return null;
if (parseIpv4(mapped.toLowerCase())) return mapped.toLowerCase();
const hexParts = mapped.split(':');
if (
hexParts.length !== 2 ||
!hexParts.every((part) => /^[0-9a-f]{1,4}$/i.test(part))
) {
return null;
}
const hi = hexParts[0];
const lo = hexParts[1];
if (!hi || !lo) return null;
const value =
(Number.parseInt(hi, 16) << 16) |
Number.parseInt(lo, 16);
return [
(value >>> 24) & 255,
(value >>> 16) & 255,
(value >>> 8) & 255,
value & 255,
].join('.');
}
function isLoopbackApiHost(hostname: string): boolean {
const host = normalizeBracketedIpv6(hostname);
if (host === 'localhost' || host === '::1') return true;
if (isLoopbackIpv4(host)) return true;
const mapped = ipv4MappedToDotted(host);
return Boolean(mapped && isLoopbackIpv4(mapped));
}
function isBlockedInternalApiHost(hostname: string): boolean {
const host = normalizeBracketedIpv6(hostname);
if (isPrivateIpv4(host)) return true;
if (/^f[cd][0-9a-f]{2}:/i.test(host)) return true;
if (/^fe[89ab][0-9a-f]:/i.test(host)) return true;
const mapped = ipv4MappedToDotted(host);
return Boolean(mapped && isPrivateIpv4(mapped));
const result = validateBaseUrl(trimmed);
return Boolean(result.parsed && !result.error);
}
export function updateCurrentApiProtocolConfig(
@ -1094,7 +1010,11 @@ export function SettingsDialog({
appearance: { title: t('settings.appearance'), subtitle: t('settings.appearanceHint') },
notifications: { title: t('settings.notifications'), subtitle: t('settings.notificationsHint') },
pet: { title: t('pet.title'), subtitle: t('pet.subtitle') },
library: { title: t('settings.library'), subtitle: t('settings.libraryHint') },
skills: { title: t('settings.skills'), subtitle: t('settings.skillsHint') },
designSystems: {
title: t('settings.designSystems'),
subtitle: t('settings.designSystemsHint'),
},
about: { title: t('settings.about'), subtitle: t('settings.aboutHint') },
};
const activeHeader = sectionHeader[activeSection];
@ -1286,13 +1206,24 @@ export function SettingsDialog({
</button>
<button
type="button"
className={`settings-nav-item${activeSection === 'library' ? ' active' : ''}`}
onClick={() => setActiveSection('library')}
className={`settings-nav-item${activeSection === 'skills' ? ' active' : ''}`}
onClick={() => setActiveSection('skills')}
>
<Icon name="grid" size={18} />
<span>
<strong>{t('settings.library')}</strong>
<small>{t('settings.libraryHint')}</small>
<strong>{t('settings.skills')}</strong>
<small>{t('settings.skillsHint')}</small>
</span>
</button>
<button
type="button"
className={`settings-nav-item${activeSection === 'designSystems' ? ' active' : ''}`}
onClick={() => setActiveSection('designSystems')}
>
<Icon name="draw" size={18} />
<span>
<strong>{t('settings.designSystems')}</strong>
<small>{t('settings.designSystemsHint')}</small>
</span>
</button>
<button
@ -1988,8 +1919,12 @@ export function SettingsDialog({
<PetSettings cfg={cfg} setCfg={setCfg} />
) : null}
{activeSection === 'library' ? (
<LibrarySection cfg={cfg} setCfg={setCfg} />
{activeSection === 'skills' ? (
<SkillsSection cfg={cfg} setCfg={setCfg} />
) : null}
{activeSection === 'designSystems' ? (
<DesignSystemsSection cfg={cfg} setCfg={setCfg} />
) : null}
{activeSection === 'about' ? (

View file

@ -0,0 +1,732 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import type { Dispatch, SetStateAction } from 'react';
import { useT } from '../i18n';
import { Icon } from './Icon';
import type { AppConfig } from '../types';
import type { SkillSummary } from '@open-design/contracts';
import {
deleteSkill,
fetchSkill,
fetchSkillFiles,
fetchSkills,
importSkill,
updateSkill,
type SkillFileEntry,
} from '../providers/registry';
// Functional skills only — design templates render in EntryView's
// Templates tab and are managed under their own daemon registry. See
// specs/current/skills-and-design-templates.md.
//
// The section is laid out as a two-column workspace:
// - Left: searchable list of skills + filters + "New" entry point
// - Right: detail panel that doubles as previewer (read mode), editor
// (when the user clicks Edit on a skill), or new-skill draft
// (when the user clicks "New skill" in the toolbar).
// Replacing the previous tab-with-design-systems layout matters because
// design-systems are now a sibling Settings section; mixing them here
// produced a long, sub-tab-gated dialog that hid both surfaces from
// each other.
interface Props {
cfg: AppConfig;
setCfg: Dispatch<SetStateAction<AppConfig>>;
}
type SourceFilter = 'all' | 'user' | 'built-in';
type DetailMode =
// Showing the SKILL.md body for the skill currently selected in the list.
| { kind: 'view'; id: string }
// Editing an existing skill — pre-fills the form with the current body.
| { kind: 'edit'; id: string }
// Drafting a brand new skill — empty form, writes to USER_SKILLS_DIR.
| { kind: 'create' }
// No skill selected and no draft in flight — shows an empty placeholder.
| { kind: 'idle' };
interface DraftState {
name: string;
description: string;
triggers: string;
body: string;
}
const EMPTY_DRAFT: DraftState = {
name: '',
description: '',
triggers: '',
body: '',
};
function summaryToDraft(skill: SkillSummary, body: string): DraftState {
return {
name: skill.name,
description: skill.description,
triggers: Array.isArray(skill.triggers) ? skill.triggers.join(', ') : '',
body,
};
}
function parseTriggers(raw: string): string[] {
return raw
.split(/[,\n]/)
.map((t) => t.trim())
.filter(Boolean);
}
export function SkillsSection({ cfg, setCfg }: Props) {
const t = useT();
const [skills, setSkills] = useState<SkillSummary[]>([]);
const [search, setSearch] = useState('');
const [sourceFilter, setSourceFilter] = useState<SourceFilter>('all');
const [modeFilter, setModeFilter] = useState<string>('all');
const [selectedId, setSelectedId] = useState<string | null>(null);
const [detailMode, setDetailMode] = useState<DetailMode>({ kind: 'idle' });
// Body for the currently-selected skill — fetched lazily so the list
// payload stays small. `null` means "not yet fetched"; `''` means
// "fetched but empty".
const [bodyById, setBodyById] = useState<Record<string, string>>({});
const [bodyLoadingId, setBodyLoadingId] = useState<string | null>(null);
// File tree for the currently-selected skill. Cached the same way as
// bodies so opening / re-opening the same skill is instant after the
// first fetch.
const [filesById, setFilesById] = useState<Record<string, SkillFileEntry[]>>({});
const [filesLoadingId, setFilesLoadingId] = useState<string | null>(null);
// Editing draft + status. The draft is held in local state so the user
// can switch away and come back without losing progress (we drop it
// only on Save / Cancel).
const [draft, setDraft] = useState<DraftState>(EMPTY_DRAFT);
const [draftError, setDraftError] = useState<string | null>(null);
const [draftSaving, setDraftSaving] = useState(false);
// Inline delete confirmation — replaces the old window.confirm() call.
// Only one skill can be in the "confirm pending" state at a time; the
// user clicks once to arm, twice to commit.
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
const refresh = useCallback(async () => {
const list = await fetchSkills();
setSkills(list);
return list;
}, []);
useEffect(() => {
void refresh();
}, [refresh]);
const disabledSkills = useMemo(
() => new Set(cfg.disabledSkills ?? []),
[cfg.disabledSkills],
);
const modeOptions = useMemo(() => {
const counts = new Map<string, number>();
for (const s of skills) {
counts.set(s.mode, (counts.get(s.mode) ?? 0) + 1);
}
return Array.from(counts.entries()).sort((a, b) => a[0].localeCompare(b[0]));
}, [skills]);
const filteredSkills = useMemo(() => {
const q = search.toLowerCase().trim();
return skills.filter((s) => {
if (modeFilter !== 'all' && s.mode !== modeFilter) return false;
if (sourceFilter !== 'all' && s.source !== sourceFilter) return false;
if (!q) return true;
const hay = `${s.name}\n${s.description}\n${(s.triggers ?? []).join(' ')}`;
return hay.toLowerCase().includes(q);
});
}, [skills, modeFilter, sourceFilter, search]);
const selectedSkill = useMemo(
() => skills.find((s) => s.id === selectedId) ?? null,
[skills, selectedId],
);
const ensureBody = useCallback(
async (id: string) => {
if (bodyById[id] !== undefined) return bodyById[id];
setBodyLoadingId(id);
try {
const detail = await fetchSkill(id);
const body = detail?.body ?? '';
setBodyById((cur) => ({ ...cur, [id]: body }));
return body;
} finally {
setBodyLoadingId((cur) => (cur === id ? null : cur));
}
},
[bodyById],
);
const ensureFiles = useCallback(
async (id: string) => {
if (filesById[id]) return filesById[id]!;
setFilesLoadingId(id);
try {
const files = await fetchSkillFiles(id);
setFilesById((cur) => ({ ...cur, [id]: files }));
return files;
} finally {
setFilesLoadingId((cur) => (cur === id ? null : cur));
}
},
[filesById],
);
const selectSkill = useCallback(
(id: string) => {
setSelectedId(id);
setDetailMode({ kind: 'view', id });
setConfirmDeleteId(null);
void ensureBody(id);
void ensureFiles(id);
},
[ensureBody, ensureFiles],
);
const startCreate = useCallback(() => {
setSelectedId(null);
setDraft(EMPTY_DRAFT);
setDraftError(null);
setDetailMode({ kind: 'create' });
setConfirmDeleteId(null);
}, []);
const startEdit = useCallback(
async (skill: SkillSummary) => {
const body = await ensureBody(skill.id);
setDraft(summaryToDraft(skill, body ?? ''));
setDraftError(null);
setDetailMode({ kind: 'edit', id: skill.id });
setConfirmDeleteId(null);
},
[ensureBody],
);
const cancelDraft = useCallback(() => {
setDraft(EMPTY_DRAFT);
setDraftError(null);
if (selectedId) {
setDetailMode({ kind: 'view', id: selectedId });
} else {
setDetailMode({ kind: 'idle' });
}
}, [selectedId]);
const submitDraft = useCallback(async () => {
if (draftSaving) return;
const name = draft.name.trim();
const body = draft.body.trim();
if (!name) {
setDraftError('Skill name is required.');
return;
}
if (!body) {
setDraftError('Skill body is required.');
return;
}
const triggers = parseTriggers(draft.triggers);
const payload = {
name,
description: draft.description.trim() || undefined,
body,
triggers,
};
setDraftSaving(true);
setDraftError(null);
const result =
detailMode.kind === 'edit'
? await updateSkill(detailMode.id, payload)
: await importSkill(payload);
setDraftSaving(false);
if ('error' in result) {
setDraftError(result.error.message);
return;
}
const updated = result.skill;
await refresh();
setBodyById((cur) => ({ ...cur, [updated.id]: body }));
// Drop the cached file tree for this id so the next selection
// re-walks the on-disk folder; SKILL.md may have been the only
// file there before, but the user might have meant to add more.
setFilesById((cur) => {
const next = { ...cur };
delete next[updated.id];
return next;
});
setSelectedId(updated.id);
setDetailMode({ kind: 'view', id: updated.id });
setDraft(EMPTY_DRAFT);
}, [detailMode, draft, draftSaving, refresh]);
const armDelete = useCallback((id: string) => {
setConfirmDeleteId(id);
}, []);
const cancelDelete = useCallback(() => {
setConfirmDeleteId(null);
}, []);
const commitDelete = useCallback(
async (id: string) => {
const result = await deleteSkill(id);
if ('error' in result) {
setDraftError(result.error.message);
return;
}
setConfirmDeleteId(null);
await refresh();
setBodyById((cur) => {
const next = { ...cur };
delete next[id];
return next;
});
setFilesById((cur) => {
const next = { ...cur };
delete next[id];
return next;
});
// Clear the disabled-skill flag so deleting a skill that was
// toggled off doesn't leave dangling preferences behind.
setCfg((c) => {
const set = new Set(c.disabledSkills ?? []);
set.delete(id);
return { ...c, disabledSkills: [...set] };
});
if (selectedId === id) {
setSelectedId(null);
setDetailMode({ kind: 'idle' });
}
},
[refresh, selectedId, setCfg],
);
const toggleEnabled = useCallback(
(id: string, enabled: boolean) => {
setCfg((c) => {
const set = new Set(c.disabledSkills ?? []);
if (enabled) set.delete(id);
else set.add(id);
return { ...c, disabledSkills: [...set] };
});
},
[setCfg],
);
const draftHeading =
detailMode.kind === 'edit'
? `Editing ${detailMode.id}`
: detailMode.kind === 'create'
? 'New skill'
: '';
return (
<section className="settings-section settings-skills">
<div className="section-head">
<div>
<h3>{t('settings.skills')}</h3>
<p className="hint">{t('settings.skillsHint')}</p>
</div>
<button
type="button"
className="btn primary"
onClick={startCreate}
data-testid="skills-new"
>
<Icon name="plus" size={14} />
<span>{t('settings.skillsNew')}</span>
</button>
</div>
<div className="library-toolbar">
<input
type="search"
className="library-search"
placeholder={t('settings.librarySearch')}
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<div className="library-filters">
{(['all', 'user', 'built-in'] as const).map((s) => {
const count =
s === 'all'
? skills.length
: skills.filter((skill) => skill.source === s).length;
return (
<button
key={s}
type="button"
className={`filter-pill${sourceFilter === s ? ' active' : ''}`}
onClick={() => setSourceFilter(s)}
>
{s === 'all' ? t('settings.libraryAll') : s}
<span className="filter-pill-count">{count}</span>
</button>
);
})}
</div>
<div className="library-filters">
<button
type="button"
className={`filter-pill${modeFilter === 'all' ? ' active' : ''}`}
onClick={() => setModeFilter('all')}
>
{t('settings.libraryAll')}
</button>
{modeOptions.map(([mode, count]) => (
<button
key={mode}
type="button"
className={`filter-pill${modeFilter === mode ? ' active' : ''}`}
onClick={() => setModeFilter(mode)}
>
{mode}
<span className="filter-pill-count">{count}</span>
</button>
))}
</div>
</div>
<div className="skills-layout">
<div className="skills-list" data-testid="skills-list">
{filteredSkills.length === 0 ? (
<p className="library-empty">{t('settings.libraryNoResults')}</p>
) : (
filteredSkills.map((skill) => {
const enabled = !disabledSkills.has(skill.id);
const isSelected = selectedId === skill.id;
return (
<div
key={skill.id}
className={`library-card${enabled ? '' : ' disabled'}${
isSelected ? ' is-selected' : ''
}`}
data-testid={`skill-row-${skill.id}`}
>
<button
type="button"
className="library-card-info skills-card-button"
onClick={() => selectSkill(skill.id)}
>
<div className="library-card-title-row">
<span className="library-card-name">{skill.name}</span>
<span className="library-card-badge">{skill.mode}</span>
{skill.source === 'user' ? (
<span
className="library-card-badge library-card-badge-user"
title="User-imported skill"
>
user
</span>
) : null}
</div>
<div className="library-card-desc">{skill.description}</div>
</button>
<label
className="toggle-switch"
title={t('settings.libraryToggleLabel')}
>
<input
type="checkbox"
checked={enabled}
onChange={(e) =>
toggleEnabled(skill.id, e.target.checked)
}
/>
<span className="toggle-slider" />
</label>
</div>
);
})
)}
</div>
<div className="skills-detail" data-testid="skills-detail">
{detailMode.kind === 'idle' ? (
<div className="skills-detail-empty">
<Icon name="grid" size={28} />
<p>{t('settings.skillsEmpty')}</p>
</div>
) : null}
{detailMode.kind === 'view' && selectedSkill ? (
<SkillDetailView
skill={selectedSkill}
body={bodyById[selectedSkill.id]}
bodyLoading={bodyLoadingId === selectedSkill.id}
files={filesById[selectedSkill.id] ?? null}
filesLoading={filesLoadingId === selectedSkill.id}
confirmDelete={confirmDeleteId === selectedSkill.id}
onEdit={() => void startEdit(selectedSkill)}
onArmDelete={() => armDelete(selectedSkill.id)}
onCancelDelete={cancelDelete}
onCommitDelete={() => void commitDelete(selectedSkill.id)}
/>
) : null}
{detailMode.kind === 'create' || detailMode.kind === 'edit' ? (
<SkillDraftForm
heading={draftHeading}
draft={draft}
setDraft={setDraft}
error={draftError}
saving={draftSaving}
isEdit={detailMode.kind === 'edit'}
onCancel={cancelDraft}
onSubmit={() => void submitDraft()}
/>
) : null}
</div>
</div>
</section>
);
}
interface SkillDetailViewProps {
skill: SkillSummary;
body: string | undefined;
bodyLoading: boolean;
files: SkillFileEntry[] | null;
filesLoading: boolean;
confirmDelete: boolean;
onEdit: () => void;
onArmDelete: () => void;
onCancelDelete: () => void;
onCommitDelete: () => void;
}
function SkillDetailView({
skill,
body,
bodyLoading,
files,
filesLoading,
confirmDelete,
onEdit,
onArmDelete,
onCancelDelete,
onCommitDelete,
}: SkillDetailViewProps) {
const t = useT();
return (
<div className="skills-detail-view">
<header className="skills-detail-head">
<div>
<h4>{skill.name}</h4>
<p className="skills-detail-meta">
{skill.mode}
{skill.source === 'user' ? ' · user' : ' · built-in'}
{skill.description ? ` · ${skill.description}` : ''}
</p>
</div>
<div className="skills-detail-actions">
<button
type="button"
className="btn ghost"
onClick={onEdit}
data-testid="skills-edit"
>
<Icon name="edit" size={12} />
<span>{t('settings.skillsEdit')}</span>
</button>
{confirmDelete ? (
<span className="skills-delete-confirm" role="group">
<button
type="button"
className="btn danger"
onClick={onCommitDelete}
data-testid="skills-delete-confirm"
>
{t('settings.skillsDeleteConfirm')}
</button>
<button type="button" className="btn ghost" onClick={onCancelDelete}>
{t('common.cancel')}
</button>
</span>
) : (
<button
type="button"
className="btn ghost"
onClick={onArmDelete}
data-testid="skills-delete"
>
<Icon name="close" size={12} />
<span>{t('settings.skillsDelete')}</span>
</button>
)}
</div>
</header>
<div className="skills-detail-grid">
<div className="skills-detail-body">
<h5>SKILL.md</h5>
{bodyLoading ? (
<p className="library-empty">{t('settings.libraryLoading')}</p>
) : (
<pre className="library-preview-body">{body ?? ''}</pre>
)}
</div>
<div className="skills-detail-files">
<h5>{t('settings.skillsFiles')}</h5>
{filesLoading ? (
<p className="library-empty">{t('settings.libraryLoading')}</p>
) : !files || files.length === 0 ? (
<p className="library-empty">{t('settings.skillsNoFiles')}</p>
) : (
<ul className="skills-file-tree">
{files.map((entry) => (
<li
key={entry.path}
className={`skills-file-entry skills-file-entry-${entry.kind}`}
style={{ paddingLeft: depthIndent(entry.path) }}
>
<Icon
name={entry.kind === 'directory' ? 'folder' : 'file'}
size={12}
/>
<span>{leafName(entry.path)}</span>
{entry.kind === 'file' && typeof entry.size === 'number' ? (
<span className="skills-file-size">
{formatSize(entry.size)}
</span>
) : null}
</li>
))}
</ul>
)}
</div>
</div>
</div>
);
}
interface SkillDraftFormProps {
heading: string;
draft: DraftState;
setDraft: Dispatch<SetStateAction<DraftState>>;
error: string | null;
saving: boolean;
isEdit: boolean;
onCancel: () => void;
onSubmit: () => void;
}
function SkillDraftForm({
heading,
draft,
setDraft,
error,
saving,
isEdit,
onCancel,
onSubmit,
}: SkillDraftFormProps) {
const t = useT();
return (
<div
className="skills-detail-draft library-import-form"
data-testid={isEdit ? 'skills-edit-form' : 'skills-create-form'}
>
<header className="skills-draft-head">
<h4>{heading}</h4>
</header>
<div className="library-import-row">
<label>
<span>{t('settings.skillsName')}</span>
<input
type="text"
value={draft.name}
onChange={(e) => setDraft((d) => ({ ...d, name: e.target.value }))}
placeholder="my-skill"
disabled={isEdit}
/>
</label>
<label>
<span>{t('settings.skillsTriggers')}</span>
<input
type="text"
value={draft.triggers}
onChange={(e) =>
setDraft((d) => ({ ...d, triggers: e.target.value }))
}
placeholder="search the web, summarize"
/>
</label>
</div>
<label className="library-import-block">
<span>{t('settings.skillsDescription')}</span>
<textarea
rows={2}
value={draft.description}
onChange={(e) =>
setDraft((d) => ({ ...d, description: e.target.value }))
}
placeholder="What does this skill do? When should the agent reach for it?"
/>
</label>
<label className="library-import-block">
<span>{t('settings.skillsBody')}</span>
<textarea
rows={14}
value={draft.body}
onChange={(e) => setDraft((d) => ({ ...d, body: e.target.value }))}
placeholder={'# My skill\n\n1. Explain the workflow.\n2. Describe the inputs and outputs.'}
/>
</label>
{error ? (
<div className="library-import-error" role="alert">
{error}
</div>
) : null}
<div className="library-import-actions">
<button
type="button"
className="btn ghost"
onClick={onCancel}
disabled={saving}
>
{t('common.cancel')}
</button>
<button
type="button"
className="btn primary"
onClick={onSubmit}
disabled={saving}
data-testid="skills-save"
>
{saving
? t('settings.skillsSaving')
: isEdit
? t('settings.skillsSave')
: t('settings.skillsCreate')}
</button>
</div>
</div>
);
}
// Each `/`-separated segment indents by 12px so a small assets/ tree
// reads as a tree without us building a nested list. Capped at 4 levels
// so bundles with deep folder hierarchies don't push the file label
// past the panel.
function depthIndent(p: string): number {
const depth = Math.min(4, p.split('/').length - 1);
return depth * 12;
}
function leafName(p: string): string {
const idx = p.lastIndexOf('/');
return idx >= 0 ? p.slice(idx + 1) : p;
}
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}

View file

@ -160,7 +160,7 @@ export const ar: Dict = {
'settings.versionUnavailable': 'تفاصيل النسخة غير متوفرة بينما البرنامج الخفي غير متصل.',
'entry.tabDesigns': 'التصاميم',
'entry.tabExamples': 'أمثلة',
'entry.tabTemplates': 'قوالب',
'entry.tabDesignSystems': 'أنظمة التصميم',
'entry.tabConnectors': 'الموصلات',
'entry.openSettingsTitle': 'الإعدادات',
@ -196,6 +196,9 @@ export const ar: Dict = {
'connectors.tools': 'الأدوات',
'connectors.connect': 'اتصال',
'connectors.disconnect': 'قطع الاتصال',
'connectors.authorizationPending': 'بانتظار التفويض...',
'connectors.authorizationPendingHint': 'أكمل التفويض في النافذة المفتوحة.',
'connectors.cancelAuthorization': 'إلغاء',
'connectors.configure': 'إعداد',
'connectors.unavailable': 'غير متاح',
'connectors.phaseStubTitle': 'واجهات الموصلات ستصل في المرحلة 3؛ هذه مجرد واجهة معاينة.',
@ -275,6 +278,8 @@ export const ar: Dict = {
'connectors.toolsSection': 'الأدوات',
'connectors.toolsLoading': 'جارٍ تحميل الأدوات…',
'connectors.noToolsAvailable': 'لا توجد أدوات متاحة بعد. بعد الاتصال ستظهر إمكانات هذا التكامل.',
'connectors.toolDetailsUnavailable': 'Tool details are unavailable, but this connector reports {n} tools.',
'connectors.loadMoreTools': 'Load more tools',
'connectors.openDetailsAria': 'فتح تفاصيل {name}',
'connectors.toolsBadgeNone': 'لا أدوات',
'connectors.toolsBadgeOne': '{n} أداة',
@ -1056,8 +1061,24 @@ export const ar: Dict = {
'settings.notifySoundBuzz': 'طنين',
'settings.notifySoundTwoToneDown': 'نغمتان هابطتان',
'settings.notifySoundThud': 'دمدمة',
'settings.library': 'المهارات وأنظمة التصميم',
'settings.libraryHint': 'تصفح ومعاينة وتفعيل/تعطيل مكتبة المحتوى الخاصة بك',
'settings.skills': 'المهارات',
'settings.skillsHint': 'المهارات الوظيفية التي يمكن للوكيل استدعاؤها أثناء المهمة',
'settings.skillsNew': 'مهارة جديدة',
'settings.skillsEmpty': 'حدد مهارة من اليسار أو أنشئ مهارة جديدة.',
'settings.skillsEdit': 'تحرير',
'settings.skillsDelete': 'حذف',
'settings.skillsDeleteConfirm': 'تأكيد الحذف',
'settings.skillsName': 'الاسم',
'settings.skillsTriggers': 'محفزات (مفصولة بفواصل أو أسطر جديدة)',
'settings.skillsDescription': 'الوصف',
'settings.skillsBody': 'محتوى SKILL.md',
'settings.skillsCreate': 'إنشاء',
'settings.skillsSave': 'حفظ',
'settings.skillsSaving': 'جاري الحفظ…',
'settings.skillsFiles': 'الملفات',
'settings.skillsNoFiles': 'لا توجد ملفات في مجلد هذه المهارة.',
'settings.designSystems': 'أنظمة التصميم',
'settings.designSystemsHint': 'تصفح وتفعيل أنظمة التصميم المتاحة للوكيل',
'settings.librarySkills': 'المهارات',
'settings.libraryDesignSystems': 'أنظمة التصميم',
'settings.librarySearch': 'بحث...',

View file

@ -160,7 +160,7 @@ export const de: Dict = {
'settings.versionUnavailable': 'Versionsdetails sind nicht verfügbar, solange der Daemon offline ist.',
'entry.tabDesigns': 'Designs',
'entry.tabExamples': 'Beispiele',
'entry.tabTemplates': 'Vorlagen',
'entry.tabDesignSystems': 'Designsysteme',
'entry.openSettingsTitle': 'Einstellungen',
'entry.openSettingsAria': 'Einstellungen öffnen',
@ -949,8 +949,24 @@ export const de: Dict = {
'settings.notifySoundBuzz': 'Summen',
'settings.notifySoundTwoToneDown': 'Zweiton abwärts',
'settings.notifySoundThud': 'Dumpfer Schlag',
'settings.library': 'Fähigkeiten & Designsysteme',
'settings.libraryHint': 'Inhaltsbibliothek durchsuchen, vorschauen und umschalten',
'settings.skills': 'Skills',
'settings.skillsHint': 'Funktionale Skills, die der Agent während einer Aufgabe aufrufen kann',
'settings.skillsNew': 'Neuer Skill',
'settings.skillsEmpty': 'Wähle links einen Skill aus oder erstelle einen neuen.',
'settings.skillsEdit': 'Bearbeiten',
'settings.skillsDelete': 'Löschen',
'settings.skillsDeleteConfirm': 'Löschen bestätigen',
'settings.skillsName': 'Name',
'settings.skillsTriggers': 'Trigger (Komma- oder zeilengetrennt)',
'settings.skillsDescription': 'Beschreibung',
'settings.skillsBody': 'SKILL.md-Inhalt',
'settings.skillsCreate': 'Erstellen',
'settings.skillsSave': 'Speichern',
'settings.skillsSaving': 'Speichern…',
'settings.skillsFiles': 'Dateien',
'settings.skillsNoFiles': 'Keine Dateien in diesem Skill-Ordner.',
'settings.designSystems': 'Design-Systeme',
'settings.designSystemsHint': 'Verfügbare Design-Systeme durchsuchen und umschalten',
'settings.librarySkills': 'Fähigkeiten',
'settings.libraryDesignSystems': 'Designsysteme',
'settings.librarySearch': 'Suchen...',

View file

@ -158,7 +158,7 @@ export const en: Dict = {
'settings.versionUnavailable': 'Version details are unavailable while the daemon is offline.',
'entry.tabDesigns': 'Designs',
'entry.tabExamples': 'Examples',
'entry.tabTemplates': 'Templates',
'entry.tabDesignSystems': 'Design systems',
'entry.tabConnectors': 'Connectors',
'entry.openSettingsTitle': 'Settings',
@ -194,6 +194,9 @@ export const en: Dict = {
'connectors.tools': 'Tools',
'connectors.connect': 'Connect',
'connectors.disconnect': 'Disconnect',
'connectors.authorizationPending': 'Waiting for authorization...',
'connectors.authorizationPendingHint': 'Complete authorization in the opened window.',
'connectors.cancelAuthorization': 'Cancel',
'connectors.configure': 'Configure',
'connectors.unavailable': 'Unavailable',
'connectors.phaseStubTitle': 'Connector APIs arrive in Phase 3; this is a preview surface.',
@ -273,6 +276,8 @@ export const en: Dict = {
'connectors.toolsSection': 'Tools',
'connectors.toolsLoading': 'Loading tools…',
'connectors.noToolsAvailable': 'No tools available yet. Connect to discover what this integration exposes.',
'connectors.toolDetailsUnavailable': 'Tool details are unavailable, but this connector reports {n} tools.',
'connectors.loadMoreTools': 'Load more tools',
'connectors.openDetailsAria': 'Open {name} details',
'connectors.toolsBadgeNone': 'No tools',
'connectors.toolsBadgeOne': '{n} tool',
@ -1102,8 +1107,24 @@ export const en: Dict = {
'settings.notifySoundBuzz': 'Buzz',
'settings.notifySoundTwoToneDown': 'Two-tone down',
'settings.notifySoundThud': 'Thud',
'settings.library': 'Skills & Design Systems',
'settings.libraryHint': 'Browse, preview, and toggle your content library',
'settings.skills': 'Skills',
'settings.skillsHint': 'Functional skills the agent can invoke mid-task',
'settings.skillsNew': 'New skill',
'settings.skillsEmpty': 'Select a skill on the left, or create a new one.',
'settings.skillsEdit': 'Edit',
'settings.skillsDelete': 'Delete',
'settings.skillsDeleteConfirm': 'Confirm delete',
'settings.skillsName': 'Name',
'settings.skillsTriggers': 'Triggers (comma- or newline-separated)',
'settings.skillsDescription': 'Description',
'settings.skillsBody': 'SKILL.md body',
'settings.skillsCreate': 'Create',
'settings.skillsSave': 'Save',
'settings.skillsSaving': 'Saving…',
'settings.skillsFiles': 'Files',
'settings.skillsNoFiles': 'No files in this skill folder.',
'settings.designSystems': 'Design Systems',
'settings.designSystemsHint': 'Browse and toggle the design systems your agent can use',
'settings.librarySkills': 'Skills',
'settings.libraryDesignSystems': 'Design Systems',
'settings.librarySearch': 'Search...',

View file

@ -160,7 +160,7 @@ export const esES: Dict = {
'settings.versionUnavailable': 'Los detalles de versión no están disponibles mientras el daemon está offline.',
'entry.tabDesigns': 'Diseños',
'entry.tabExamples': 'Ejemplos',
'entry.tabTemplates': 'Plantillas',
'entry.tabDesignSystems': 'Sistemas de diseño',
'entry.openSettingsTitle': 'Ajustes',
'entry.openSettingsAria': 'Abrir ajustes',
@ -950,8 +950,24 @@ export const esES: Dict = {
'settings.notifySoundBuzz': 'Zumbido',
'settings.notifySoundTwoToneDown': 'Dos tonos descendente',
'settings.notifySoundThud': 'Golpe',
'settings.library': 'Habilidades y sistemas de diseño',
'settings.libraryHint': 'Explorar, previsualizar y activar/desactivar tu biblioteca de contenidos',
'settings.skills': 'Habilidades',
'settings.skillsHint': 'Habilidades funcionales que el agente puede invocar durante la tarea',
'settings.skillsNew': 'Nueva habilidad',
'settings.skillsEmpty': 'Selecciona una habilidad a la izquierda o crea una nueva.',
'settings.skillsEdit': 'Editar',
'settings.skillsDelete': 'Eliminar',
'settings.skillsDeleteConfirm': 'Confirmar eliminación',
'settings.skillsName': 'Nombre',
'settings.skillsTriggers': 'Disparadores (separados por comas o saltos de línea)',
'settings.skillsDescription': 'Descripción',
'settings.skillsBody': 'Cuerpo de SKILL.md',
'settings.skillsCreate': 'Crear',
'settings.skillsSave': 'Guardar',
'settings.skillsSaving': 'Guardando…',
'settings.skillsFiles': 'Archivos',
'settings.skillsNoFiles': 'No hay archivos en esta carpeta de habilidad.',
'settings.designSystems': 'Sistemas de diseño',
'settings.designSystemsHint': 'Explora y activa los sistemas de diseño disponibles',
'settings.librarySkills': 'Habilidades',
'settings.libraryDesignSystems': 'Sistemas de diseño',
'settings.librarySearch': 'Buscar...',

View file

@ -158,7 +158,7 @@ export const fa: Dict = {
'settings.versionUnavailable': 'تا وقتی daemon آفلاین است جزئیات نسخه در دسترس نیست.',
'entry.tabDesigns': 'طرح‌ها',
'entry.tabExamples': 'نمونهها',
'entry.tabTemplates': 'قالبها',
'entry.tabDesignSystems': 'سیستم‌های طراحی',
'entry.tabConnectors': 'اتصال‌دهنده‌ها',
'entry.tabImageTemplates': 'قالب‌های تصویر',
@ -195,6 +195,9 @@ export const fa: Dict = {
'connectors.tools': 'ابزارها',
'connectors.connect': 'اتصال',
'connectors.disconnect': 'قطع اتصال',
'connectors.authorizationPending': 'در انتظار مجوز...',
'connectors.authorizationPendingHint': 'مجوز را در پنجره بازشده کامل کنید.',
'connectors.cancelAuthorization': 'لغو',
'connectors.configure': 'پیکربندی',
'connectors.unavailable': 'در دسترس نیست',
'connectors.phaseStubTitle': 'APIهای اتصال‌دهنده در فاز ۳ می‌رسند؛ این فقط یک نمای پیش‌نمایش است.',
@ -274,6 +277,8 @@ export const fa: Dict = {
'connectors.toolsSection': 'ابزارها',
'connectors.toolsLoading': 'در حال بارگیری ابزارها…',
'connectors.noToolsAvailable': 'هنوز ابزاری در دسترس نیست. پس از اتصال، قابلیت‌های این ادغام آشکار می‌شود.',
'connectors.toolDetailsUnavailable': 'Tool details are unavailable, but this connector reports {n} tools.',
'connectors.loadMoreTools': 'Load more tools',
'connectors.openDetailsAria': 'باز کردن جزئیات {name}',
'connectors.toolsBadgeNone': 'بدون ابزار',
'connectors.toolsBadgeOne': '{n} ابزار',
@ -1088,8 +1093,24 @@ export const fa: Dict = {
'settings.notifySoundBuzz': 'وزوز',
'settings.notifySoundTwoToneDown': 'دو نوای پایین‌رونده',
'settings.notifySoundThud': 'تالاپ',
'settings.library': 'مهارت‌ها و سیستم‌های طراحی',
'settings.libraryHint': 'مرور، پیش‌نمایش و فعال/غیرفعال‌سازی کتابخانه محتوای شما',
'settings.skills': 'مهارت‌ها',
'settings.skillsHint': 'مهارت‌های کاربردی که عامل می‌تواند در حین یک وظیفه فراخوانی کند',
'settings.skillsNew': 'مهارت جدید',
'settings.skillsEmpty': 'یک مهارت را از سمت چپ انتخاب کنید یا یکی بسازید.',
'settings.skillsEdit': 'ویرایش',
'settings.skillsDelete': 'حذف',
'settings.skillsDeleteConfirm': 'تأیید حذف',
'settings.skillsName': 'نام',
'settings.skillsTriggers': 'محرک‌ها (با کاما یا خط جدید جدا شوند)',
'settings.skillsDescription': 'توضیحات',
'settings.skillsBody': 'متن SKILL.md',
'settings.skillsCreate': 'ایجاد',
'settings.skillsSave': 'ذخیره',
'settings.skillsSaving': 'در حال ذخیره…',
'settings.skillsFiles': 'فایل‌ها',
'settings.skillsNoFiles': 'هیچ فایلی در این پوشه مهارت نیست.',
'settings.designSystems': 'سیستم‌های طراحی',
'settings.designSystemsHint': 'سیستم‌های طراحی موجود را مرور و فعال کنید',
'settings.librarySkills': 'مهارت‌ها',
'settings.libraryDesignSystems': 'سیستم‌های طراحی',
'settings.librarySearch': 'جستجو...',

View file

@ -160,7 +160,7 @@ export const fr: Dict = {
'settings.versionUnavailable': 'Les informations de version sont indisponibles lorsque le daemon est hors ligne.',
'entry.tabDesigns': 'Designs',
'entry.tabExamples': 'Exemples',
'entry.tabTemplates': 'Modèles',
'entry.tabDesignSystems': 'Design systems',
'entry.tabConnectors': 'Connecteurs',
'entry.openSettingsTitle': 'Paramètres',
@ -196,6 +196,9 @@ export const fr: Dict = {
'connectors.tools': 'Outils',
'connectors.connect': 'Connecter',
'connectors.disconnect': 'Déconnecter',
'connectors.authorizationPending': 'En attente dautorisation...',
'connectors.authorizationPendingHint': 'Terminez lautorisation dans la fenêtre ouverte.',
'connectors.cancelAuthorization': 'Annuler',
'connectors.configure': 'Configurer',
'connectors.unavailable': 'Indisponible',
'connectors.phaseStubTitle': 'Les API de connecteurs arrivent en phase 3 ; ceci est un aperçu.',
@ -275,6 +278,8 @@ export const fr: Dict = {
'connectors.toolsSection': 'Outils',
'connectors.toolsLoading': 'Chargement des outils…',
'connectors.noToolsAvailable': 'Aucun outil disponible pour le moment. Connectez-vous pour découvrir les capacités de cette intégration.',
'connectors.toolDetailsUnavailable': 'Tool details are unavailable, but this connector reports {n} tools.',
'connectors.loadMoreTools': 'Load more tools',
'connectors.openDetailsAria': 'Ouvrir les détails de {name}',
'connectors.toolsBadgeNone': 'Aucun outil',
'connectors.toolsBadgeOne': '{n} outil',
@ -1056,8 +1061,24 @@ export const fr: Dict = {
'settings.notifySoundBuzz': 'Buzz',
'settings.notifySoundTwoToneDown': 'Bitonale descendante',
'settings.notifySoundThud': 'Sourd',
'settings.library': 'Compétences et systèmes de design',
'settings.libraryHint': 'Parcourir, prévisualiser et activer/désactiver votre bibliothèque de contenus',
'settings.skills': 'Compétences',
'settings.skillsHint': 'Compétences que lagent peut invoquer en cours de tâche',
'settings.skillsNew': 'Nouvelle compétence',
'settings.skillsEmpty': 'Sélectionnez une compétence à gauche, ou créez-en une.',
'settings.skillsEdit': 'Modifier',
'settings.skillsDelete': 'Supprimer',
'settings.skillsDeleteConfirm': 'Confirmer la suppression',
'settings.skillsName': 'Nom',
'settings.skillsTriggers': 'Déclencheurs (séparés par virgules ou retours à la ligne)',
'settings.skillsDescription': 'Description',
'settings.skillsBody': 'Corps SKILL.md',
'settings.skillsCreate': 'Créer',
'settings.skillsSave': 'Enregistrer',
'settings.skillsSaving': 'Enregistrement…',
'settings.skillsFiles': 'Fichiers',
'settings.skillsNoFiles': 'Aucun fichier dans ce dossier de compétence.',
'settings.designSystems': 'Design systems',
'settings.designSystemsHint': 'Parcourez et activez les design systems disponibles',
'settings.librarySkills': 'Compétences',
'settings.libraryDesignSystems': 'Systèmes de design',
'settings.librarySearch': 'Rechercher...',

Some files were not shown because too many files have changed in this diff Show more