Refresh desktop integration control plane (#123)

* feat(dev): add desktop tools-dev control plane

* refactor(sidecar): split Open Design contracts

Move Open Design-specific sidecar protocol definitions into @open-design/contracts so sidecar and platform can remain descriptor-driven primitives.

* refactor(daemon): organize package sources

Keep daemon app code, tests, and sidecar entrypoints in separate package directories so each layer can be built and verified independently.

* chore(repo): streamline maintenance entrypoints

Centralize agent guidance by directory and reduce root command chains while preserving the existing build scope.

* docs: translate agent guidance to English

* fix(sidecar): tolerate stale IPC sockets

Remove stale Unix socket files only after confirming no listener is active, so tools-dev can restart after unclean shutdowns.
This commit is contained in:
PerishFire 2026-04-30 14:23:53 +08:00 committed by GitHub
parent 56d08b8c5f
commit c6d11018a0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
93 changed files with 5160 additions and 363 deletions

2
.gitignore vendored
View file

@ -2,6 +2,8 @@ node_modules/
dist/
out/
.next/
.next-*/
.tmp/
.DS_Store
*.log
.vite

150
AGENTS.md
View file

@ -1,53 +1,121 @@
# AGENTS.md
# Directory guide
## Project shape
This file is the single source of truth for agents entering this repository. Read this file first; after entering `apps/`, `packages/`, or `tools/`, read that layer's `AGENTS.md` for module-level details. Do not copy module details back into the root file; root stays focused on cross-repository boundaries, workflow, and commands.
- pnpm workspace with packages from `pnpm-workspace.yaml`: `apps/web`, `apps/daemon`, `packages/contracts`, and `e2e`.
- Runtime target is Node `~24` with `pnpm@10.33.2`; use Corepack so the pinned pnpm version from `package.json` is selected.
- `apps/web` is a Next.js 16 App Router + React 18 client. Entrypoints: `apps/web/app/`, main client shell `apps/web/src/App.tsx`.
- `packages/contracts` is the shared, pure TypeScript web/daemon contract layer for API DTOs, SSE events, task states, and unified errors.
- `apps/daemon` is the local Express + SQLite process and the `od` bin (`apps/daemon/dist/cli.js` after build). It owns `/api/*`, agent spawning, skills, design systems, artifacts, and static serving.
- `e2e` contains both Playwright UI specs (`e2e/specs`) and Vitest/jsdom integration tests (`e2e/tests`).
## Core documentation index
## Commands
- 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`.
- Directory-level agent guidance: `apps/AGENTS.md`, `packages/AGENTS.md`, `tools/AGENTS.md`.
- Install: `corepack enable && pnpm install`
- Full local dev: `pnpm dev:all` — starts daemon and Next together. Defaults are daemon `:7456`, web `:3000`; busy ports are probed forward and exported as `OD_PORT` / `NEXT_PORT`.
- Web only: `pnpm dev` from the root starts Next; pair it with `pnpm daemon` when API routes are needed.
- Production local path: `pnpm build` writes the static Next export to `apps/web/out/`; `pnpm start` builds and serves that export through the daemon.
- Main verification: `pnpm typecheck && pnpm test && pnpm build`
- Residual JavaScript check: `pnpm check:residual-js`; root `pnpm typecheck` runs it after package and support typechecks.
- Package tests: `pnpm --filter @open-design/web test`, `pnpm --filter @open-design/daemon test`, `pnpm --filter @open-design/e2e test`
- Focused Vitest: `pnpm --dir apps/web exec vitest run -c vitest.config.ts src/providers/sse.test.ts` (adjust package dir and test path as needed).
- Playwright UI: `pnpm test:ui`; headed: `pnpm test:ui:headed`. Playwright starts `pnpm dev:all` with isolated data under `e2e/.od-data` and strict dynamic ports.
- Live adapter smoke: `pnpm test:e2e:live` runs `e2e/scripts/runtime-adapter.e2e.live.test.ts` through Node strip-types.
## Workspace directories
## TypeScript and boundary conventions
- Workspace packages come from `pnpm-workspace.yaml`: `apps/*`, `packages/*`, `tools/*`, and `e2e`.
- `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.
- `packages/contracts` is the pure TypeScript web/daemon app contract layer.
- `packages/sidecar-proto` owns the Open Design sidecar business protocol; `packages/sidecar` owns the generic sidecar runtime; `packages/platform` owns generic OS process primitives.
- `tools/dev` is the only currently active local development lifecycle control plane.
- `e2e` contains Playwright UI specs and Vitest/jsdom integration tests.
- New project-owned entrypoints, modules, scripts, tests, reporters, and configs use TypeScript. The residual JavaScript allowlist is limited to generated output, vendored dependencies, and compatibility build artifacts such as `apps/daemon/dist/**/*.{js,mjs,cjs}`, `apps/web/.next/**/*.{js,mjs,cjs}`, and `apps/web/out/**/*.{js,mjs,cjs}`.
- Shared web/daemon contracts go in `packages/contracts`; keep this package free of Next.js, Express, Node filesystem/process APIs, browser APIs, SQLite, and daemon internals.
- Keep UI-only state and presentation unions in `apps/web`; import daemon-facing API, SSE, task, and error contracts from `@open-design/contracts`.
- Keep local capability logic in `apps/daemon`: filesystem, SQLite, agent CLI spawning, task lifecycle, logs, artifacts, skills, design systems, and static serving.
- Runtime validation policy and schema enforcement belong to the later validation workstream; current shared contracts define the typed target shape.
## Inactive or placeholder directories
## Runtime data and ports
- `apps/nextjs` and `packages/shared` have been removed; do not recreate or reference them.
- `apps/packaged` is only a placeholder for future packaged app assembly; do not add active code there in this round.
- `tools/pack` is only a placeholder for future `tools-pack`; do not add commands or packaging logic there in this round.
- `.od/`, `.tmp/`, `e2e/.od-data`, Playwright reports, and agent scratch directories are local runtime data and must stay out of git.
- The daemon auto-creates local data under `.od/` by default: SQLite at `.od/app.sqlite`, per-project agent CWDs at `.od/projects/<id>/`, saved renders at `.od/artifacts/`.
- Keep `.od/`, `e2e/.od-data`, Playwright reports, and agent scratch dirs out of git; `.gitignore` already covers them.
- `OD_DATA_DIR` relocates daemon data relative to the repo root; Playwright uses this for isolated runs.
- In development, `apps/web/next.config.ts` rewrites `/api/*`, `/artifacts/*`, and `/frames/*` to the daemon port. In production, the daemon serves `apps/web/out/` directly.
# Development workflow
## Agent, skill, and design-system wiring
## Environment baseline
- The daemon scans `PATH` for local CLIs in `apps/daemon/agents.ts` and spawns them with `cwd` pinned to `.od/projects/<id>/`.
- Agent stdout parsing is per transport: Claude stream JSON, Copilot stream JSON, ACP JSON-RPC, or plain text. Changes to CLI args belong in `apps/daemon/agents.ts` and matching parser tests.
- Skills are folder bundles under `skills/` with `SKILL.md`; extended `od:` frontmatter is parsed by `apps/daemon/skills.ts`. Restart the daemon after adding or changing skill folders.
- Design systems are `design-systems/*/DESIGN.md`; `scripts/sync-design-systems.ts` re-imports upstream systems.
- Prompt composition lives in `apps/web/src/prompts/system.ts`, `discovery.ts`, and `directions.ts`; artifacts are parsed/rendered through `apps/web/src/artifacts/` and `apps/web/src/runtime/`.
- Runtime target is Node `~24` and `pnpm@10.33.2`; use Corepack so the pnpm version pinned in `package.json` is selected.
- New project-owned entrypoints, modules, scripts, tests, reporters, and configs should default to TypeScript.
- Residual JavaScript is limited to generated output, vendored dependencies, explicitly documented compatibility build artifacts, and the allowlist in `scripts/check-residual-js.ts`.
## Testing notes
## Local lifecycle
- Web Vitest includes `apps/web/src/**/*.test.{ts,tsx}` in a Node environment.
- Daemon Vitest includes `apps/daemon/**/*.test.{ts,tsx}` in a Node environment.
- E2E Vitest includes `e2e/tests/**/*.test.{ts,tsx}` in jsdom with automatic React JSX.
- Playwright uses Chromium only, writes reports under `e2e/reports/`, and reuses an existing server outside CI.
- Use `pnpm tools-dev` as the only local development lifecycle entry point.
- Do not add or restore root lifecycle aliases: `pnpm dev`, `pnpm dev:all`, `pnpm daemon`, `pnpm preview`, or `pnpm start`.
- Ports are governed by `tools-dev` flags: `--daemon-port` and `--web-port`.
- `tools-dev` exports `OD_PORT` for the web proxy target and `OD_WEB_PORT` for the web listener; do not use `NEXT_PORT`.
## Boundary constraints
- App business logic must not know about sidecar/control-plane concepts. Keep sidecar awareness in `apps/<app>/sidecar` or the desktop sidecar entry wrapper.
- Shared web/daemon app contracts belong in `packages/contracts`; that package must not depend on Next.js, Express, Node filesystem/process APIs, browser APIs, SQLite, daemon internals, or the sidecar control-plane protocol.
- Sidecar process stamps must have exactly five fields: `app`, `mode`, `namespace`, `ipc`, and `source`.
- Orchestration layers (`tools-dev`, future `tools-pack`, packaged launchers) must call package primitives; do not hand-build `--od-stamp-*` args or process-scan regexes.
- Default runtime files live under `<project-root>/.tmp/<source>/<namespace>/...`; POSIX IPC sockets are fixed at `/tmp/open-design/ipc/<namespace>/<app>.sock`.
## Validation strategy
- After package, workspace, or command-entry changes, run `pnpm install` so workspace links and generated dist entries stay fresh.
- Before marking regular work ready, run at least `pnpm typecheck` and `pnpm test`; run `pnpm build` as well when build boundaries are involved.
- For the web/e2e loop, prefer `pnpm tools-dev run web --daemon-port <port> --web-port <port>`.
- On a GUI-capable machine, validate desktop by running `pnpm tools-dev`, then `pnpm tools-dev inspect desktop status`.
- Stamp/namespace changes must validate two concurrent namespaces and run desktop `inspect eval` plus `inspect screenshot` for each namespace.
- Path/log changes must run `pnpm tools-dev logs --namespace <name> --json` and confirm log paths are under `.tmp/tools-dev/<namespace>/...`.
# Common commands
```bash
pnpm install
pnpm tools-dev
pnpm tools-dev start web
pnpm tools-dev run web --daemon-port 17456 --web-port 17573
pnpm tools-dev status --json
pnpm tools-dev logs --json
pnpm tools-dev inspect desktop status --json
pnpm tools-dev inspect desktop screenshot --path /tmp/open-design.png
pnpm tools-dev stop
pnpm tools-dev check
```
```bash
pnpm typecheck
pnpm test
pnpm build
pnpm test:ui
pnpm test:ui:headed
pnpm test:e2e:live
pnpm check:residual-js
```
```bash
pnpm --filter @open-design/web typecheck
pnpm --filter @open-design/daemon test
pnpm --filter @open-design/desktop build
pnpm --filter @open-design/tools-dev build
pnpm -r --if-present run typecheck
pnpm -r --if-present run test
```
# FAQ
## Why is there no root `pnpm dev` / `pnpm start`?
To avoid starting daemon, web, and desktop through inconsistent env, port, namespace, or log paths. All local lifecycle flows must go through `pnpm tools-dev`.
## Why should `apps/nextjs` not be restored?
The current web runtime is `apps/web`. The historical `apps/nextjs` layout has been removed from the active repo shape; restoring it would reintroduce duplicate app boundaries and stale scripts.
## How does desktop discover the web URL?
Desktop queries runtime status through sidecar IPC. The web URL comes from `tools-dev` launch status, not from desktop guessing ports or reading web internals.
## How are sidecar-proto, sidecar, and platform split?
`@open-design/sidecar-proto` owns Open Design app/mode/source constants, namespace validation, stamp fields/flags, IPC message schema, status shapes, and error semantics. `@open-design/sidecar` provides only generic bootstrap, IPC transport, path/runtime resolution, launch env, and JSON runtime files. `@open-design/platform` provides only generic OS process stamp serialization, command parsing, and process matching/search primitives, consuming the proto descriptor.
## Where is data written?
The daemon writes `.od/` by default: SQLite at `.od/app.sqlite`, agent CWDs under `.od/projects/<id>/`, and saved renders under `.od/artifacts/`. `OD_DATA_DIR` can relocate data relative to the repo root; Playwright uses it to isolate test data.
## When is `pnpm install` required?
Run `pnpm install` after changing package manifests, workspace layout, command entrypoints, bin/link-related content, or after adding/removing workspace packages.

View file

@ -14,7 +14,7 @@ This guide tells you exactly where to look for each type of contribution and wha
|---|---|---|---|
| Make OD render a new kind of artifact (an invoice, an iOS Settings screen, a one-pager…) | a **Skill** | [`skills/<your-skill>/`](skills/) | one folder, ~2 files |
| Make OD speak a new brand's visual language | a **Design System** | [`design-systems/<brand>/DESIGN.md`](design-systems/) | one Markdown file |
| Hook up a new coding-agent CLI | an **Agent adapter** | [`apps/daemon/agents.js`](apps/daemon/agents.js) | ~10 lines in one array |
| Hook up a new coding-agent CLI | an **Agent adapter** | [`apps/daemon/src/agents.ts`](apps/daemon/src/agents.ts) | ~10 lines in one array |
| Add a feature, fix a bug, lift a UX pattern from [`open-codesign`][ocod] | code | `apps/web/src/`, `apps/daemon/` | normal PR |
| Improve docs, port a section to 中文, fix typos | docs | `README.md`, `README.zh-CN.md`, `docs/`, `QUICKSTART.md` | one PR |
@ -31,7 +31,7 @@ git clone https://github.com/nexu-io/open-design.git
cd open-design
corepack enable # selects the pinned pnpm from packageManager
pnpm install
pnpm dev:all # daemon (:7456) + Next dev (:3000)
pnpm tools-dev run web # daemon + web foreground loop
pnpm typecheck # tsc -b --noEmit
pnpm build # production build
```
@ -164,13 +164,13 @@ The 9-section schema is fixed — that's what skill bodies grep for. The first H
4. **No marketing fluff.** The brand's tagline is not a design token. Cut it.
5. **Slug uses ASCII**`linear.app` becomes `linear-app`, `x.ai` becomes `x-ai`. The 69 imported systems already follow this convention; mirror it.
The 69 product systems we ship are imported from [`VoltAgent/awesome-design-md`][acd2] via [`scripts/sync-design-systems.mjs`](scripts/sync-design-systems.mjs). If your brand belongs upstream, **send the PR there first** — we'll pick it up automatically on the next sync. The `design-systems/` folder is for systems that don't fit upstream, plus our two hand-authored starters.
The 69 product systems we ship are imported from [`VoltAgent/awesome-design-md`][acd2] via [`scripts/sync-design-systems.ts`](scripts/sync-design-systems.ts). If your brand belongs upstream, **send the PR there first** — we'll pick it up automatically on the next sync. The `design-systems/` folder is for systems that don't fit upstream, plus our two hand-authored starters.
---
## Adding a new coding-agent CLI
Hooking up a new agent (e.g. some new shop's `foo-coder` CLI) is one entry in [`apps/daemon/agents.js`](apps/daemon/agents.js):
Hooking up a new agent (e.g. some new shop's `foo-coder` CLI) is one entry in [`apps/daemon/src/agents.ts`](apps/daemon/src/agents.ts):
```javascript
{
@ -183,7 +183,7 @@ Hooking up a new agent (e.g. some new shop's `foo-coder` CLI) is one entry in [`
}
```
That's it — daemon will detect it on `PATH`, the picker shows it, the chat path works. If the CLI emits **typed events** (like Claude Code's `--output-format stream-json`), wire a parser in [`apps/daemon/claude-stream.js`](apps/daemon/claude-stream.js) and set `streamFormat: 'claude-stream-json'`.
That's it — daemon will detect it on `PATH`, the picker shows it, the chat path works. If the CLI emits **typed events** (like Claude Code's `--output-format stream-json`), wire a parser in [`apps/daemon/src/claude-stream.ts`](apps/daemon/src/claude-stream.ts) and set `streamFormat: 'claude-stream-json'`.
Bar for merging:
@ -226,7 +226,7 @@ We don't enforce a CLA. Apache-2.0 covers us; your contribution is licensed unde
Open an issue with:
- What you ran (the exact `pnpm dev:all` / `pnpm start` invocation).
- What you ran (the exact `pnpm tools-dev ...` invocation).
- Which agent CLI was selected (or whether you were on the BYOK path).
- The skill + design system pair that triggered it.
- The relevant **daemon stderr tail** — most "the artifact never rendered" reports get diagnosed in 30 seconds when we can see `spawn ENOENT` or the CLI's actual error.

View file

@ -14,7 +14,7 @@
|---|---|---|---|
| 让 OD 渲染一种新的 artifact一份发票、一个 iOS 设置页、一张 one-pager…… | 一个 **Skill** | [`skills/<your-skill>/`](skills/) | 一个文件夹,约 2 个文件 |
| 让 OD 说一种新品牌的视觉语言 | 一套 **Design System** | [`design-systems/<brand>/DESIGN.md`](design-systems/) | 一个 Markdown 文件 |
| 接入一个新的 coding-agent CLI | 一个 **Agent adapter** | [`apps/daemon/agents.js`](apps/daemon/agents.js) | 一个数组里 ~10 行 |
| 接入一个新的 coding-agent CLI | 一个 **Agent adapter** | [`apps/daemon/src/agents.ts`](apps/daemon/src/agents.ts) | 一个数组里 ~10 行 |
| 加功能、修 bug、从 [`open-codesign`][ocod] 移植一个 UX 模式 | 代码 | `apps/web/src/`、`apps/daemon/` | 普通 PR |
| 改文档、补中文翻译、修错别字 | 文档 | `README.md`、`README.zh-CN.md`、`docs/`、`QUICKSTART.md` | 一个 PR |
@ -31,7 +31,7 @@ git clone https://github.com/nexu-io/open-design.git
cd open-design
corepack enable # 使用 packageManager 固定的 pnpm
pnpm install
pnpm dev:all # daemon (:7456) + Next dev (:3000)
pnpm tools-dev run web # daemon + web 前台闭环
pnpm typecheck # tsc -b --noEmit
pnpm build # 生产构建
```
@ -163,13 +163,13 @@ design-systems/your-brand/
4. **不要营销废话。** 品牌的 tagline 不是设计 token。删掉。
5. **slug 用 ASCII** —— `linear.app` 写成 `linear-app``x.ai` 写成 `x-ai`。已经导入的 69 套都遵循这个约定,跟着写。
我们内置的 69 套产品系统是通过 [`scripts/sync-design-systems.mjs`](scripts/sync-design-systems.mjs) 从 [`VoltAgent/awesome-design-md`][acd2] 导入的。如果你的品牌应该归属在上游,**请先把 PR 发到那里** —— 我们下一次同步会自动收上来。`design-systems/` 文件夹用来放那些**不适合归到上游**的系统、加上我们手写的两套 starter。
我们内置的 69 套产品系统是通过 [`scripts/sync-design-systems.ts`](scripts/sync-design-systems.ts) 从 [`VoltAgent/awesome-design-md`][acd2] 导入的。如果你的品牌应该归属在上游,**请先把 PR 发到那里** —— 我们下一次同步会自动收上来。`design-systems/` 文件夹用来放那些**不适合归到上游**的系统、加上我们手写的两套 starter。
---
## 接入一个新的 coding-agent CLI
接入一个新 agent比如某个新 shop 的 `foo-coder` CLI就是在 [`apps/daemon/agents.js`](apps/daemon/agents.js) 里加一项:
接入一个新 agent比如某个新 shop 的 `foo-coder` CLI就是在 [`apps/daemon/src/agents.ts`](apps/daemon/src/agents.ts) 里加一项:
```javascript
{
@ -182,7 +182,7 @@ design-systems/your-brand/
}
```
完事 —— daemon 会在 `PATH` 上检测到它、picker 显示出来、对话路径就通了。如果这个 CLI 吐 **类型化事件**(像 Claude Code 的 `--output-format stream-json`),在 [`apps/daemon/claude-stream.js`](apps/daemon/claude-stream.js) 里写一个 parser并把 `streamFormat` 设成 `'claude-stream-json'`
完事 —— daemon 会在 `PATH` 上检测到它、picker 显示出来、对话路径就通了。如果这个 CLI 吐 **类型化事件**(像 Claude Code 的 `--output-format stream-json`),在 [`apps/daemon/src/claude-stream.ts`](apps/daemon/src/claude-stream.ts) 里写一个 parser并把 `streamFormat` 设成 `'claude-stream-json'`
合并硬线:
@ -225,7 +225,7 @@ design-systems/your-brand/
开 issue 时请带上:
- 你跑的命令(精确到 `pnpm dev:all` / `pnpm start`)。
- 你跑的命令(精确到 `pnpm tools-dev ...`)。
- 选中的 agent CLI 是哪个(或者你走的是 BYOK 路径)。
- 触发问题时的 skill + design system 组合。
- 相关的 **daemon stderr 末尾几行** —— 大多数「artifact 没渲染出来」的报告,看到 `spawn ENOENT` 或 CLI 实际报错后 30 秒就能定位。

View file

@ -33,8 +33,8 @@ corepack pnpm --version # should print 10.33.2
```bash
corepack enable
pnpm install
pnpm dev:all # starts daemon (:7456) + Next dev (:3000) together
open http://localhost:3000
pnpm tools-dev run web # starts daemon + web in the foreground
# open the web URL printed by tools-dev
```
On first load, the app detects your installed code-agent CLI (Claude Code / Codex / Gemini / OpenCode / Cursor Agent / Qwen), picks it automatically, and defaults to `web-prototype` skill + `Neutral Modern` design system. Type a prompt and hit **Send**. The agent streams into the left pane; the `<artifact>` tag is parsed out and the HTML renders live on the right. When it finishes, click **Save to disk** to persist the artifact under `./.od/artifacts/<timestamp>-<slug>/index.html`.
@ -51,15 +51,19 @@ Pair a skill with a design system and a single prompt produces a layout-appropri
## Other scripts
```bash
pnpm daemon # just the daemon (no web UI build)
pnpm dev # just Next.js dev server on :3000
pnpm build # production build + static export to apps/web/out/
pnpm preview # build, then serve apps/web/out/ through the daemon locally
pnpm start # build + daemon serving apps/web/out/ (single-process prod mode)
pnpm typecheck # tsc -b --noEmit
pnpm tools-dev # daemon + web + desktop in the background
pnpm tools-dev start web # daemon + web in the background
pnpm tools-dev run web # daemon + web in the foreground (e2e/dev server)
pnpm tools-dev status # inspect managed runtimes
pnpm tools-dev logs # show daemon/web/desktop logs
pnpm tools-dev stop # stop managed runtimes
pnpm build # production build + static export to apps/web/out/
pnpm typecheck # workspace typecheck
```
Root scripts are orchestration only. App-specific commands live in workspace packages (`apps/web`, `apps/daemon`, and `e2e`), but the root aliases above are the normal entry points.
`pnpm tools-dev` is the only local lifecycle entry point. Do not use the removed legacy root aliases (`pnpm dev`, `pnpm dev:all`, `pnpm daemon`, `pnpm preview`, `pnpm start`).
During local development, `tools-dev` starts the daemon first, passes its port into `apps/web`, and `apps/web/next.config.ts` rewrites `/api/*`, `/artifacts/*`, and `/frames/*` to that daemon port so the App Router app can talk to the sibling Express process without CORS setup.
For the daemon-only production mode, the daemon serves the static Next.js export itself at `http://localhost:7456`, so no reverse proxy is involved.
@ -84,8 +88,6 @@ location /api/ {
}
```
During local development, `apps/web/next.config.ts` rewrites `/api/*`, `/artifacts/*`, and `/frames/*` to the daemon port so the App Router app can talk to the sibling Express process without CORS setup.
## Two execution modes
| Mode | Picker value | How a request flows |
@ -113,13 +115,15 @@ Swap the skill or the design system in the top bar and the next send uses the ne
open-design/
├── apps/
│ ├── daemon/ # Node/Express — spawns local agents + serves APIs
│ │ ├── cli.js # `od` bin entry (also used by npm scripts)
│ │ ├── server.js # /api/agents /api/skills /api/design-systems /api/chat /api/upload /api/artifacts/save
│ │ ├── agents.js # PATH scanner for claude/codex/gemini/opencode/cursor-agent/qwen/copilot
│ │ ├── skills.js # SKILL.md loader (frontmatter parser)
│ │ ├── design-systems.js # DESIGN.md loader
│ │ └── frontmatter.js # tiny YAML-subset parser (no deps)
│ └── web/ # Next.js 16 App Router + React client
│ │ └── src/
│ │ ├── cli.ts # `od` bin entry
│ │ ├── server.ts # /api/* + static serving
│ │ ├── agents.ts # PATH scanner for claude/codex/gemini/opencode/cursor-agent/qwen/copilot
│ │ ├── skills.ts # SKILL.md loader (frontmatter parser)
│ │ └── design-systems.ts # DESIGN.md loader
│ │ ├── sidecar/ # tools-dev daemon sidecar wrapper
│ │ └── tests/ # daemon package tests
│ ├── web/ # Next.js 16 App Router + React client
│ ├── app/ # App Router entrypoints
│ ├── src/ # shared React + TypeScript client/runtime modules
│ │ ├── App.tsx # orchestrates mode / skill / DS pickers + send
@ -128,7 +132,15 @@ open-design/
│ │ ├── artifacts/ # streaming <artifact> parser + manifests
│ │ ├── runtime/ # iframe srcdoc, markdown, export helpers
│ │ └── state/ # localStorage + daemon-backed project state
│ └── next.config.ts # dev rewrites + prod apps/web/out export config
│ ├── sidecar/ # tools-dev web sidecar wrapper
│ └── next.config.ts # tools-dev rewrites + prod apps/web/out export config
│ └── desktop/ # Electron runtime, launched/inspected by tools-dev
├── packages/
│ ├── contracts/ # shared web/daemon app contracts
│ ├── sidecar-proto/ # Open Design sidecar protocol contract
│ ├── sidecar/ # generic sidecar runtime primitives
│ └── platform/ # generic process/platform primitives
├── tools/dev/ # `pnpm tools-dev` lifecycle and inspect CLI
├── e2e/ # Playwright UI + external integration/Vitest harness
├── skills/ # SKILL.md — drops in from any Claude Code skill repo
│ ├── web-prototype/ # generic single-screen prototype (default for prototype mode)
@ -148,20 +160,20 @@ open-design/
│ ├── warm-editorial/ # Warm Editorial (starter)
│ ├── README.md # catalog overview
│ └── …69 product systems # claude · cohere · linear-app · vercel · stripe · airbnb …
├── scripts/sync-design-systems.mjs # re-import from upstream getdesign tarball
├── scripts/sync-design-systems.ts # re-import from upstream getdesign tarball
├── docs/ # product vision + spec
├── .od/ # runtime data (gitignored, auto-created)
│ ├── app.sqlite # projects / conversations / messages / tabs
│ ├── artifacts/ # one-off "Save to disk" renders
│ └── projects/<id>/ # per-project working dir + agent cwd
├── pnpm-workspace.yaml # apps/* + e2e
└── package.json # root orchestration scripts + `od` bin
├── pnpm-workspace.yaml # apps/* + packages/* + tools/* + e2e
└── package.json # root quality scripts + `od` bin
```
## Troubleshooting
- **"no agents found on PATH"** — install one of: `claude`, `codex`, `gemini`, `opencode`, `cursor-agent`, `qwen`, `copilot`. Or switch to "Anthropic API · BYOK" in the top bar and paste a key in **Settings**.
- **daemon 500 on /api/chat** — check the daemon terminal for the stderr tail; usually the CLI rejected its args. Different CLIs take different argv shapes; see `apps/daemon/agents.js` `buildArgs` if you need to tweak.
- **daemon 500 on /api/chat** — check the daemon terminal for the stderr tail; usually the CLI rejected its args. Different CLIs take different argv shapes; see `apps/daemon/src/agents.ts` `buildArgs` if you need to tweak.
- **artifact never renders** — the model produced text without wrapping in `<artifact>`. Confirm the system prompt is going through (check daemon log) and consider switching to a more capable model or a stricter skill.
## Mapping back to the vision
@ -169,6 +181,6 @@ open-design/
This Quickstart is the runnable seed of the spec in [`docs/`](docs/). The spec describes where this grows (see [`docs/roadmap.md`](docs/roadmap.md)). Highlights:
- `docs/architecture.md` describes the shipped stack: Next.js 16 App Router in front, local daemon behind it, and `apps/web/next.config.ts` rewrites in dev to keep the browser talking to the same `/api` surface.
- `docs/skills-protocol.md` describes the full `od:` frontmatter (typed inputs, sliders, capability gating). This MVP reads `name` / `description` / `triggers` / `od.mode` / `od.design_system.requires` only — extend `apps/daemon/skills.js` to add the rest.
- `docs/agent-adapters.md` foresees richer dispatch (capability detection, streaming tool-calls). Our `apps/daemon/agents.js` is a minimal dispatcher — enough to prove the wiring.
- `docs/skills-protocol.md` describes the full `od:` frontmatter (typed inputs, sliders, capability gating). This MVP reads `name` / `description` / `triggers` / `od.mode` / `od.design_system.requires` only — extend `apps/daemon/src/skills.ts` to add the rest.
- `docs/agent-adapters.md` foresees richer dispatch (capability detection, streaming tool-calls). Our `apps/daemon/src/agents.ts` is a minimal dispatcher — enough to prove the wiring.
- `docs/modes.md` lists four modes: prototype / deck / template / design-system. We ship skills for the first two; the picker already filters by `mode`.

View file

@ -22,7 +22,7 @@
Anthropic의 [Claude Design][cd](2026-04-17 출시, Opus 4.7 기반)은 LLM이 장문의 글쓰기를 멈추고 디자인 산출물을 직접 내놓기 시작했을 때 어떤 일이 일어나는지 보여주었습니다. 순식간에 화제가 되었지만, 여전히 **클로즈드 소스**, 유료, 클라우드 전용, Anthropic 모델과 Anthropic 내부 skill에 종속된 상태입니다. 체크아웃도, 자가 호스팅도, Vercel 배포도, 에이전트 교체도 불가능합니다.
**Open Design(OD)은 그 오픈소스 대안입니다.** 동일한 루프, 동일한 '아티팩트 우선' 사고방식, 락인 없음. 에이전트를 직접 만들지 않습니다 — 가장 강력한 코딩 에이전트는 이미 여러분의 노트북에 있습니다. 우리는 그것을 skill 기반 디자인 워크플로에 연결할 뿐입니다. 로컬에서는 `pnpm dev:all`로 실행하고, 웹 레이어는 Vercel에 배포할 수 있으며, 모든 레이어에서 BYOK(자체 키 사용)가 가능합니다.
**Open Design(OD)은 그 오픈소스 대안입니다.** 동일한 루프, 동일한 '아티팩트 우선' 사고방식, 락인 없음. 에이전트를 직접 만들지 않습니다 — 가장 강력한 코딩 에이전트는 이미 여러분의 노트북에 있습니다. 우리는 그것을 skill 기반 디자인 워크플로에 연결할 뿐입니다. 로컬에서는 `pnpm tools-dev`로 실행하고, 웹 레이어는 Vercel에 배포할 수 있으며, 모든 레이어에서 BYOK(자체 키 사용)가 가능합니다.
`시드 라운드를 위한 매거진 스타일 피치덱 만들어줘`라고 입력하세요. 모델이 픽셀 하나 그리기 전에 **초기화 질문 폼**이 먼저 등장합니다. 에이전트는 5개의 엄선된 시각적 방향 중 하나를 선택합니다. 실시간 `TodoWrite` 계획 카드가 UI에 스트리밍됩니다. Daemon이 디스크에 실제 프로젝트 폴더를 생성하며, seed 템플릿, 레이아웃 라이브러리, 자가 점검 체크리스트가 포함됩니다. 에이전트는 **pre-flight를 강제**로 읽고, 자신의 출력물에 대해 **5차원 검토**를 실행하며, 몇 초 후 샌드박스 iframe에 렌더링되는 단일 `<artifact>`를 내보냅니다.
@ -45,7 +45,7 @@ OD는 네 개의 오픈소스 프로젝트 어깨 위에 서 있습니다:
| **시각적 방향** | 5개의 엄선된 학파(Editorial Monocle · Modern Minimal · Tech Utility · Brutalist · Soft Warm) — 각각 결정론적 OKLch 팔레트 + 폰트 스택 제공 |
| **기기 프레임** | iPhone 15 Pro · Pixel · iPad Pro · MacBook · Browser Chrome — 픽셀 정확도, 스킬 간 공유 |
| **에이전트 런타임** | 로컬 daemon이 프로젝트 폴더에서 CLI를 실행 — 에이전트가 실제 디스크 환경에 대한 실제 `Read`, `Write`, `Bash`, `WebFetch` 도구를 사용 |
| **배포 대상** | 로컬(`pnpm dev:all`) · Vercel 웹 레이어 · 단일 프로세스 프로덕션(`pnpm start`) |
| **배포 대상** | 로컬(`pnpm tools-dev`) · Vercel 웹 레이어 · daemon 정적 서빙 프로덕션 |
| **라이선스** | Apache-2.0 |
[acd2]: https://github.com/VoltAgent/awesome-design-md
@ -262,8 +262,8 @@ cd open-design
corepack enable
corepack pnpm --version # 10.33.2가 출력되어야 합니다
pnpm install
pnpm dev:all # daemon (:7456) + Next dev (:3000)
open http://localhost:3000
pnpm tools-dev run web # daemon + web foreground
# tools-dev가 출력한 web URL을 여세요
```
환경 요구사항: Node `~24`와 pnpm `10.33.x`. `nvm`/`fnm`은 선택적 보조 도구일 뿐입니다; 사용한다면 `pnpm install` 전에 `nvm install 24 && nvm use 24` 또는 `fnm install 24 && fnm use 24`를 실행하세요.
@ -291,7 +291,7 @@ Daemon은 저장소 루트에 하나의 숨겨진 폴더를 소유합니다. 그
| 원하는 작업 | 방법 |
|---|---|
| 내용 확인 | `ls -la .od && sqlite3 .od/app.sqlite '.tables'` |
| 초기 상태로 재설정 | daemon 중지, `rm -rf .od`, `pnpm dev:all` 재실행 |
| 초기 상태로 재설정 | `pnpm tools-dev stop`, `rm -rf .od`, `pnpm tools-dev run web` 재실행 |
| 다른 위치로 이동 | 아직 지원되지 않음 — 경로가 저장소 상대 경로로 하드코딩됨 |
전체 파일 맵, 스크립트, 트러블슈팅 → [`QUICKSTART.md`](QUICKSTART.md).
@ -374,7 +374,7 @@ open-design/
│ └── deck-framework.html ← 덱 기준선(nav / counter / print)
├── scripts/
│ └── sync-design-systems.mjs ← 상위 awesome-design-md tarball 재가져오기
│ └── sync-design-systems.ts ← 상위 awesome-design-md tarball 재가져오기
├── docs/
│ ├── spec.md ← 제품 스펙, 시나리오, 차별화
@ -424,7 +424,7 @@ open-design/
</details>
라이브러리는 [`scripts/sync-design-systems.mjs`](scripts/sync-design-systems.mjs)를 통해 [`VoltAgent/awesome-design-md`][acd2]에서 가져옵니다. 재실행하면 새로 고침됩니다.
라이브러리는 [`scripts/sync-design-systems.ts`](scripts/sync-design-systems.ts)를 통해 [`VoltAgent/awesome-design-md`][acd2]에서 가져옵니다. 재실행하면 새로 고침됩니다.
## 시각적 방향
@ -496,7 +496,7 @@ daemon 부팅 시 `PATH`에서 자동 감지됩니다. 설정 필요 없음.
| [GitHub Copilot CLI](https://github.com/features/copilot/cli) | `copilot` | `--output-format json` (타입 이벤트) | `copilot -p <prompt> --allow-all-tools --output-format json` |
| Anthropic API · BYOK | n/a | SSE 직접 | PATH에 CLI가 없을 때 브라우저 폴백 |
새 CLI 추가는 [`apps/daemon/agents.js`](apps/daemon/agents.js)에 항목 하나 추가하는 것입니다. 스트리밍 형식은 `claude-stream-json`(타입 이벤트) 또는 `plain`(원시 텍스트) 중 하나입니다.
새 CLI 추가는 [`apps/daemon/src/agents.ts`](apps/daemon/src/agents.ts)에 항목 하나 추가하는 것입니다. 스트리밍 형식은 `claude-stream-json`(타입 이벤트) 또는 `plain`(원시 텍스트) 중 하나입니다.
## 참조 및 계보
@ -509,7 +509,7 @@ daemon 부팅 시 `PATH`에서 자동 감지됩니다. 설정 필요 없음.
| [**`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), 로컬 우선 스토리지 허브, `SKILL.md` 취향 주입. 로드맵의 UX 패턴: 코멘트 모드 수술적 편집, AI 제안 트윅 패널. **[`pi-ai`][piai]는 의도적으로 vendor하지 않습니다** — open-codesign은 이를 에이전트 런타임으로 번들링하지만; 우리는 사용자가 이미 가진 CLI에 위임합니다. |
| [`VoltAgent/awesome-claude-design`][acd] / [`awesome-design-md`][acd2] | 9섹션 `DESIGN.md` 스키마의 출처이자 [`scripts/sync-design-systems.mjs`](scripts/sync-design-systems.mjs)를 통해 가져온 69개 제품 시스템. |
| [`VoltAgent/awesome-claude-design`][acd] / [`awesome-design-md`][acd2] | 9섹션 `DESIGN.md` 스키마의 출처이자 [`scripts/sync-design-systems.ts`](scripts/sync-design-systems.ts)를 통해 가져온 69개 제품 시스템. |
| [`farion1231/cc-switch`](https://github.com/farion1231/cc-switch) | 여러 에이전트 CLI에 걸친 심링크 기반 skill 배포의 영감. |
| [Claude Code skills][skill] | 원본 그대로 채택된 `SKILL.md` 규약 — 모든 Claude Code skill이 `skills/`에 드롭되면 daemon이 감지합니다. |
@ -547,7 +547,7 @@ daemon 부팅 시 `PATH`에서 자동 감지됩니다. 설정 필요 없음.
- **skill 추가** — [`SKILL.md`][skill] 규약을 따르는 폴더를 [`skills/`](skills/)에 드롭하세요.
- **디자인 시스템 추가** — 9섹션 스키마를 사용하여 [`design-systems/<brand>/`](design-systems/)에 `DESIGN.md`를 드롭하세요.
- **새 코딩 에이전트 CLI 연결** — [`apps/daemon/agents.js`](apps/daemon/agents.js)에 항목 하나 추가.
- **새 코딩 에이전트 CLI 연결** — [`apps/daemon/src/agents.ts`](apps/daemon/src/agents.ts)에 항목 하나 추가.
전체 설명, 병합 기준, 코드 스타일, 받지 않는 것 → [`CONTRIBUTING.md`](CONTRIBUTING.md) ([简体中文](CONTRIBUTING.zh-CN.md)).

View file

@ -22,7 +22,7 @@
Anthropic's [Claude Design][cd] (released 2026-04-17, Opus 4.7) showed what happens when an LLM stops writing prose and starts shipping design artifacts. It went viral — and stayed closed-source, paid-only, cloud-only, locked to Anthropic's model and Anthropic's skills. There is no checkout, no self-host, no Vercel deploy, no swap-in-your-own-agent.
**Open Design (OD) is the open-source alternative.** Same loop, same artifact-first mental model, none of the lock-in. We don't ship an agent — the strongest coding agents already live on your laptop. We wire them into a skill-driven design workflow that runs locally with `pnpm dev:all`, can deploy the web layer to Vercel, and stays BYOK at every layer.
**Open Design (OD) is the open-source alternative.** Same loop, same artifact-first mental model, none of the lock-in. We don't ship an agent — the strongest coding agents already live on your laptop. We wire them into a skill-driven design workflow that runs locally with `pnpm tools-dev`, can deploy the web layer to Vercel, and stays BYOK at every layer.
Type `make me a magazine-style pitch deck for our seed round`. The interactive question form pops up before the model improvises a single pixel. The agent picks one of five curated visual directions. A live `TodoWrite` plan streams into the UI. The daemon builds a real on-disk project folder with a seed template, layout library, and self-check checklist. The agent reads them — pre-flight enforced — runs a five-dimensional critique against its own output, and emits a single `<artifact>` that renders in a sandboxed iframe seconds later.
@ -45,7 +45,7 @@ OD stands on four open-source shoulders:
| **Visual directions** | 5 curated schools (Editorial Monocle · Modern Minimal · Tech Utility · Brutalist · Soft Warm) — each ships a deterministic OKLch palette + font stack |
| **Device frames** | iPhone 15 Pro · Pixel · iPad Pro · MacBook · Browser Chrome — pixel-accurate, shared across screens |
| **Agent runtime** | Local daemon spawns the CLI in your project folder — agent gets real `Read`, `Write`, `Bash`, `WebFetch` against a real on-disk environment |
| **Deployable to** | Local (`pnpm dev:all`) · Vercel web layer · Single-process prod (`pnpm start`) |
| **Deployable to** | Local (`pnpm tools-dev`) · Vercel web layer |
| **License** | Apache-2.0 |
[acd2]: https://github.com/VoltAgent/awesome-design-md
@ -262,8 +262,8 @@ cd open-design
corepack enable
corepack pnpm --version # should print 10.33.2
pnpm install
pnpm dev:all # daemon (:7456) + Next dev (:3000)
open http://localhost:3000
pnpm tools-dev run web
# open the web URL printed by tools-dev
```
Environment requirements: Node `~24` and pnpm `10.33.x`. `nvm`/`fnm` are optional helpers only; if you use one, run `nvm install 24 && nvm use 24` or `fnm install 24 && fnm use 24` before `pnpm install`.
@ -291,7 +291,7 @@ The daemon owns one hidden folder at the repo root. Everything in it is gitignor
| Want to… | Do this |
|---|---|
| Inspect what's in there | `ls -la .od && sqlite3 .od/app.sqlite '.tables'` |
| Reset to a clean slate | stop the daemon, `rm -rf .od`, run `pnpm dev:all` again |
| Reset to a clean slate | `pnpm tools-dev stop`, `rm -rf .od`, run `pnpm tools-dev run web` again |
| Move it elsewhere | not supported yet — the path is hard-coded relative to the repo |
Full file map, scripts, and troubleshooting → [`QUICKSTART.md`](QUICKSTART.md).
@ -373,7 +373,7 @@ open-design/
│ └── deck-framework.html ← deck baseline (nav / counter / print)
├── scripts/
│ └── sync-design-systems.mjs ← re-import upstream awesome-design-md tarball
│ └── sync-design-systems.ts ← re-import upstream awesome-design-md tarball
├── docs/
│ ├── spec.md ← product spec, scenarios, differentiation
@ -423,7 +423,7 @@ open-design/
</details>
The library is imported via [`scripts/sync-design-systems.mjs`](scripts/sync-design-systems.mjs) from [`VoltAgent/awesome-design-md`][acd2]. Re-run to refresh.
The library is imported via [`scripts/sync-design-systems.ts`](scripts/sync-design-systems.ts) from [`VoltAgent/awesome-design-md`][acd2]. Re-run to refresh.
## Visual directions
@ -495,7 +495,7 @@ Auto-detected from `PATH` on daemon boot. No config required.
| [GitHub Copilot CLI](https://github.com/features/copilot/cli) | `copilot` | `--output-format json` (typed events) | `copilot -p <prompt> --allow-all-tools --output-format json` |
| Anthropic API · BYOK | n/a | SSE direct | Browser fallback when no CLI is on PATH |
Adding a new CLI is one entry in [`apps/daemon/agents.js`](apps/daemon/agents.js). Streaming format is one of `claude-stream-json` (typed events) or `plain` (raw text).
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` (typed events) or `plain` (raw text).
## References & lineage
@ -508,7 +508,7 @@ Every external project this repo borrows from. Each link goes to the source so y
| [**`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. UX patterns on our roadmap: comment-mode surgical edits, 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. |
| [`VoltAgent/awesome-claude-design`][acd] / [`awesome-design-md`][acd2] | Source of the 9-section `DESIGN.md` schema and 69 product systems imported via [`scripts/sync-design-systems.mjs`](scripts/sync-design-systems.mjs). |
| [`VoltAgent/awesome-claude-design`][acd] / [`awesome-design-md`][acd2] | Source of the 9-section `DESIGN.md` schema and 69 product systems imported via [`scripts/sync-design-systems.ts`](scripts/sync-design-systems.ts). |
| [`farion1231/cc-switch`](https://github.com/farion1231/cc-switch) | Inspiration for symlink-based skill distribution across multiple agent CLIs. |
| [Claude Code skills][skill] | The `SKILL.md` convention adopted verbatim — any Claude Code skill drops into `skills/` and is picked up by the daemon. |
@ -546,7 +546,7 @@ Issues, PRs, new skills, and new design systems are all welcome. The highest-lev
- **Add a skill** — drop a folder into [`skills/`](skills/) following the [`SKILL.md`][skill] convention.
- **Add a design system** — drop a `DESIGN.md` into [`design-systems/<brand>/`](design-systems/) using the 9-section schema.
- **Wire up a new coding-agent CLI** — one entry in [`apps/daemon/agents.js`](apps/daemon/agents.js).
- **Wire up a new coding-agent CLI** — one entry in [`apps/daemon/src/agents.ts`](apps/daemon/src/agents.ts).
Full walkthrough, bar-for-merging, code style, and what we don't accept → [`CONTRIBUTING.md`](CONTRIBUTING.md) ([简体中文](CONTRIBUTING.zh-CN.md)).

View file

@ -22,7 +22,7 @@
Anthropic 的 [Claude Design][cd]2026-04-17 发布,基于 Opus 4.7)让大家第一次看到:当一个 LLM 不再写废话、开始直接交付设计成品,会是什么样子。它瞬间出圈 —— 然后保持**闭源**、付费、只跑在云上、绑定 Anthropic 的模型和 Anthropic 的内部 skill。没有 checkout没有自托管没有 Vercel 部署,也换不了自己的 agent。
**Open DesignOD就是它的开源替代品。** 同一套 loop、同一种「artifact-first」心智模型但没有锁定。我们不做 agent —— 你笔记本上最强的 coding agent 已经装好了。我们要做的,是把它接进一个 skill 驱动的设计工作流:本地用 `pnpm dev:all` 跑完整 web + daemon,云端可单独部署 Web 层,每一层都 BYOK自带 Key
**Open DesignOD就是它的开源替代品。** 同一套 loop、同一种「artifact-first」心智模型但没有锁定。我们不做 agent —— 你笔记本上最强的 coding agent 已经装好了。我们要做的,是把它接进一个 skill 驱动的设计工作流:本地用 `pnpm tools-dev` 跑完整本地闭环,云端可单独部署 Web 层,每一层都 BYOK自带 Key
输入「帮我做一份杂志风的种子轮 pitch deck」。在模型挥洒第一个像素之前**初始化问题表单**已经先跳出来。Agent 从 5 套精挑的视觉方向里选一个。一张活的 `TodoWrite` 计划卡片实时流入 UI。Daemon 在磁盘上构建出一个真实的项目目录,里面有 seed 模板、布局库、自检 checklist。Agent **强制 pre-flight** 读取它们,对自己的输出跑一轮**五维评审**,几秒后吐出一个 `<artifact>`,渲染在沙盒 iframe 里。
@ -45,7 +45,7 @@ OD 站在四个开源项目的肩膀上:
| **视觉方向** | 5 套精选流派Editorial Monocle · Modern Minimal · Tech Utility · Brutalist · Soft Warm每一套自带 OKLch 色板 + 字体栈 |
| **设备外壳** | iPhone 15 Pro · Pixel · iPad Pro · MacBook · Browser Chrome —— 像素级精确,跨 skill 共享 |
| **Agent 运行时** | 本地 daemon 在你的项目目录里 spawn CLI —— agent 拥有真实的 `Read` / `Write` / `Bash` / `WebFetch`,作用在真实磁盘上 |
| **部署目标** | 本地 `pnpm dev:all` · Vercel Web 层 · 单进程生产 (`pnpm start`) |
| **部署目标** | 本地 `pnpm tools-dev` · Vercel Web 层 |
| **License** | Apache-2.0 |
[acd2]: https://github.com/VoltAgent/awesome-design-md
@ -262,8 +262,8 @@ cd open-design
corepack enable
corepack pnpm --version # 应输出 10.33.2
pnpm install
pnpm dev:all # daemon (:7456) + Next dev (:3000) 一起起
open http://localhost:3000
pnpm tools-dev run web
# 打开 tools-dev 输出的 web URL
```
环境要求Node `~24`pnpm `10.33.x`。`nvm` / `fnm` 只是可选辅助工具,不是项目必需步骤;如果使用它们,先执行 `nvm install 24 && nvm use 24``fnm install 24 && fnm use 24`,再运行 `pnpm install`
@ -291,7 +291,7 @@ Daemon 在仓库根下维护一个隐藏目录,里面所有内容都已 gitign
| 想做什么 | 怎么做 |
|---|---|
| 看一眼里面有啥 | `ls -la .od && sqlite3 .od/app.sqlite '.tables'` |
| 完全清空,从零再来 | 先停 daemon`rm -rf .od`,然后重新 `pnpm dev:all` |
| 完全清空,从零再来 | `pnpm tools-dev stop`,再 `rm -rf .od`,然后重新 `pnpm tools-dev run web` |
| 换到别的位置 | 暂不支持 —— 路径是相对仓库根写死的 |
完整文件地图、脚本、排错 → [`QUICKSTART.md`](QUICKSTART.md)。
@ -370,7 +370,7 @@ open-design/
│ └── deck-framework.html ← deck 基线nav / counter / print
├── scripts/
│ └── sync-design-systems.mjs ← 从上游 awesome-design-md tarball 重新导入
│ └── sync-design-systems.ts ← 从上游 awesome-design-md tarball 重新导入
├── docs/
│ ├── spec.md ← 产品定义、场景、差异化
@ -420,7 +420,7 @@ open-design/
</details>
整个库通过 [`scripts/sync-design-systems.mjs`](scripts/sync-design-systems.mjs) 从 [`VoltAgent/awesome-design-md`][acd2] 导入。重新执行即可刷新。
整个库通过 [`scripts/sync-design-systems.ts`](scripts/sync-design-systems.ts) 从 [`VoltAgent/awesome-design-md`][acd2] 导入。重新执行即可刷新。
## 视觉方向
@ -492,7 +492,7 @@ Daemon 启动时从 `PATH` 自动检测,无需配置。
| [GitHub Copilot CLI](https://github.com/features/copilot/cli) | `copilot` | `--output-format json`(类型化事件) | `copilot -p <prompt> --allow-all-tools --output-format json` |
| Anthropic API · BYOK | n/a | SSE 直连 | 没装任何 CLI 时的浏览器兜底 |
加一个新 CLI = 在 [`apps/daemon/agents.js`](apps/daemon/agents.js) 里加一项。流式格式从 `claude-stream-json`(类型化事件)和 `plain`(原始文本)两种里选一个。
加一个新 CLI = 在 [`apps/daemon/src/agents.ts`](apps/daemon/src/agents.ts) 里加一项。流式格式从 `claude-stream-json`(类型化事件)和 `plain`(原始文本)两种里选一个。
## 引用与师承
@ -505,7 +505,7 @@ Daemon 启动时从 `PATH` 自动检测,无需配置。
| [**`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。 |
| [`VoltAgent/awesome-claude-design`][acd] / [`awesome-design-md`][acd2] | 9 段式 `DESIGN.md` schema 的来源69 套产品系统通过 [`scripts/sync-design-systems.mjs`](scripts/sync-design-systems.mjs) 导入。 |
| [`VoltAgent/awesome-claude-design`][acd] / [`awesome-design-md`][acd2] | 9 段式 `DESIGN.md` schema 的来源69 套产品系统通过 [`scripts/sync-design-systems.ts`](scripts/sync-design-systems.ts) 导入。 |
| [`farion1231/cc-switch`](https://github.com/farion1231/cc-switch) | 跨多个 agent CLI 的 symlink 式 skill 分发灵感来源。 |
| [Claude Code skills][skill] | `SKILL.md` 规范原样采纳 —— 任何 Claude Code skill 丢进 `skills/` 都能被 daemon 识别。 |
@ -543,7 +543,7 @@ Daemon 启动时从 `PATH` 自动检测,无需配置。
- **加一个 skill** —— 往 [`skills/`](skills/) 丢一个文件夹,遵循 [`SKILL.md`][skill] 规范。
- **加一套 design system** —— 往 [`design-systems/<brand>/`](design-systems/) 丢一份 `DESIGN.md`,用 9 段式 schema。
- **接入一个新的 coding-agent CLI** —— 在 [`apps/daemon/agents.js`](apps/daemon/agents.js) 里加一项。
- **接入一个新的 coding-agent CLI** —— 在 [`apps/daemon/src/agents.ts`](apps/daemon/src/agents.ts) 里加一项。
完整流程、合并硬线、代码风格、我们不接收的 PR 类型 → [`CONTRIBUTING.zh-CN.md`](CONTRIBUTING.zh-CN.md)[English](CONTRIBUTING.md))。

38
apps/AGENTS.md Normal file
View file

@ -0,0 +1,38 @@
# apps/AGENTS.md
Follow the root `AGENTS.md` first. This file only records module-level boundaries for `apps/`.
## Active apps
- `apps/web`: Next.js 16 App Router + React 18 web runtime. Entrypoints live in `apps/web/app/`; the main client shell is `apps/web/src/App.tsx`. During local `tools-dev` web runs, `apps/web/next.config.ts` rewrites `/api/*`, `/artifacts/*`, and `/frames/*` to `OD_PORT`.
- `apps/daemon`: Express + SQLite local daemon and `od` bin. It owns REST/SSE APIs, agent CLI spawning, skills, design systems, artifact persistence, static serving, and local data under `.od/`.
- `apps/desktop`: Electron shell. Desktop does not guess the web port; it reads runtime status through sidecar IPC and opens the reported web URL.
## Daemon layout
- `apps/daemon/src/` contains only daemon app source.
- `apps/daemon/tests/` contains daemon tests.
- `apps/daemon/sidecar/` contains the daemon sidecar entry.
- CLI/agent argument changes or stdout parser changes belong in `apps/daemon/src/agents.ts` and the matching parser tests.
## Sidecar awareness
- App business layers must not import sidecar packages or branch on `runtime.mode`, `namespace`, `ipc`, or `source`.
- Keep sidecar awareness in `apps/<app>/sidecar` or the desktop sidecar entry wrapper.
## Inactive app directories
- `apps/nextjs` has been removed; do not restore it.
- `apps/packaged` is a minimal placeholder for future packaged app assembly. Do not add a package manifest, runtime code, or lifecycle script there in this round.
## Common app commands
```bash
pnpm --filter @open-design/web typecheck
pnpm --filter @open-design/web test
pnpm --filter @open-design/daemon typecheck
pnpm --filter @open-design/daemon test
pnpm --filter @open-design/daemon build
pnpm --filter @open-design/desktop typecheck
pnpm --filter @open-design/desktop build
```

View file

@ -7,15 +7,18 @@
"od": "./dist/cli.js"
},
"scripts": {
"build": "tsc -p tsconfig.json",
"build": "tsc -p tsconfig.json && tsc -p tsconfig.sidecar.json",
"daemon": "pnpm run build && node dist/cli.js --no-open",
"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"
"typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.sidecar.json --noEmit && tsc -p tsconfig.tests.json --noEmit"
},
"dependencies": {
"@open-design/contracts": "workspace:*",
"@open-design/contracts": "workspace:0.1.0",
"@open-design/platform": "workspace:0.1.0",
"@open-design/sidecar": "workspace:0.1.0",
"@open-design/sidecar-proto": "workspace:0.1.0",
"better-sqlite3": "^11.10.0",
"express": "^4.19.2",
"jszip": "^3.10.1",

View file

@ -0,0 +1,24 @@
import { APP_KEYS, OPEN_DESIGN_SIDECAR_CONTRACT } from "@open-design/sidecar-proto";
import { bootstrapSidecarRuntime } from "@open-design/sidecar";
import { readProcessStamp } from "@open-design/platform";
import { startDaemonSidecar } from "./server.js";
async function main(): Promise<void> {
const stamp = readProcessStamp(process.argv.slice(2), OPEN_DESIGN_SIDECAR_CONTRACT);
if (stamp == null) throw new Error("sidecar stamp is required");
const runtime = bootstrapSidecarRuntime(stamp, process.env, {
app: APP_KEYS.DAEMON,
contract: OPEN_DESIGN_SIDECAR_CONTRACT,
});
const server = await startDaemonSidecar(runtime);
process.stdout.write(`${JSON.stringify(await server.status(), null, 2)}\n`);
await server.waitUntilStopped();
}
void main().catch((error: unknown) => {
console.error(error instanceof Error ? error.stack || error.message : String(error));
process.exit(1);
});

View file

@ -0,0 +1,129 @@
import type { Server } from "node:http";
import {
SIDECAR_ENV,
SIDECAR_MESSAGES,
normalizeDaemonSidecarMessage,
type DaemonStatusSnapshot,
type SidecarStamp,
} from "@open-design/sidecar-proto";
import {
createJsonIpcServer,
type JsonIpcServerHandle,
type SidecarRuntimeContext,
} from "@open-design/sidecar";
import { startServer } from "../src/server.js";
const DAEMON_PORT_ENV = SIDECAR_ENV.DAEMON_PORT;
const TOOLS_DEV_PARENT_PID_ENV = SIDECAR_ENV.TOOLS_DEV_PARENT_PID;
export type DaemonSidecarHandle = {
status(): Promise<DaemonStatusSnapshot>;
stop(): Promise<void>;
waitUntilStopped(): Promise<void>;
};
function parsePort(value: string | undefined): number {
if (value == null || value.trim().length === 0) return 0;
const port = Number(value);
if (!Number.isInteger(port) || port <= 0 || port > 65535) {
throw new Error(`${DAEMON_PORT_ENV} must be an integer between 1 and 65535`);
}
return port;
}
async function closeHttpServer(server: Server): Promise<void> {
if (!server.listening) return;
await new Promise<void>((resolveClose, rejectClose) => {
server.close((error) => (error == null ? resolveClose() : rejectClose(error)));
});
}
function isProcessAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
function attachParentMonitor(stop: () => Promise<void>): void {
const parentPid = Number(process.env[TOOLS_DEV_PARENT_PID_ENV]);
if (!Number.isInteger(parentPid) || parentPid <= 0) return;
const timer = setInterval(() => {
if (isProcessAlive(parentPid)) return;
clearInterval(timer);
void stop().finally(() => process.exit(0));
}, 1000);
timer.unref();
}
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 };
if (typeof started === "string") {
throw new Error("daemon startServer did not return a server handle");
}
const serverHandle = started;
const state: DaemonStatusSnapshot = {
pid: process.pid,
state: "running",
updatedAt: new Date().toISOString(),
url: serverHandle.url,
};
let ipcServer: JsonIpcServerHandle | null = null;
let stopped = false;
let resolveStopped!: () => void;
const stoppedPromise = new Promise<void>((resolveStop) => {
resolveStopped = resolveStop;
});
async function stop(): Promise<void> {
if (stopped) return;
stopped = true;
state.state = "stopped";
state.updatedAt = new Date().toISOString();
await ipcServer?.close().catch(() => undefined);
await closeHttpServer(serverHandle.server).catch(() => undefined);
resolveStopped();
}
attachParentMonitor(stop);
ipcServer = await createJsonIpcServer({
socketPath: runtime.ipc,
handler: async (message: unknown) => {
const request = normalizeDaemonSidecarMessage(message);
switch (request.type) {
case SIDECAR_MESSAGES.STATUS:
return { ...state };
case SIDECAR_MESSAGES.SHUTDOWN:
setImmediate(() => {
void stop().finally(() => process.exit(0));
});
return { accepted: true };
}
},
});
for (const signal of ["SIGINT", "SIGTERM"] as const) {
process.on(signal, () => {
void stop().finally(() => process.exit(0));
});
}
return {
async status() {
return { ...state };
},
stop,
waitUntilStopped() {
return stoppedPromise;
},
};
}

View file

@ -845,7 +845,7 @@ export async function startServer({ port = 7456, returnServer = false } = {}) {
// Project files. Each project owns a flat folder under .od/projects/<id>/
// containing every file the user has uploaded, pasted, sketched, or that
// the agent has generated. Names are sanitized; paths are confined to the
// project's own folder (see apps/daemon/projects.js).
// project's own folder (see apps/daemon/src/projects.ts).
app.get('/api/projects/:id/files', async (req, res) => {
try {
const files = await listFiles(PROJECTS_DIR, req.params.id);

View file

@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
import { validateArtifactManifestInput } from './artifact-manifest.js';
import { validateArtifactManifestInput } from '../src/artifact-manifest.js';
function validBase() {
return {

View file

@ -1,7 +1,7 @@
// @ts-nocheck
import { test } from 'vitest';
import assert from 'node:assert/strict';
import { createJsonEventStreamHandler } from './json-event-stream.js';
import { createJsonEventStreamHandler } from '../src/json-event-stream.js';
test('opencode json stream emits text and usage events', () => {
const events = [];

View file

@ -1,16 +1,16 @@
import path from 'node:path';
import { describe, expect, it } from 'vitest';
import { resolveProjectRoot } from './server.js';
import { resolveProjectRoot } from '../src/server.js';
describe('resolveProjectRoot', () => {
it('resolves the repository root from the source daemon directory', () => {
const root = path.resolve(import.meta.dirname, '../..');
const root = path.resolve(import.meta.dirname, '../../..');
expect(resolveProjectRoot(path.join(root, 'apps', 'daemon'))).toBe(root);
});
it('resolves the repository root from the compiled daemon dist directory', () => {
const root = path.resolve(import.meta.dirname, '../..');
const root = path.resolve(import.meta.dirname, '../../..');
expect(resolveProjectRoot(path.join(root, 'apps', 'daemon', 'dist'))).toBe(root);
});

View file

@ -2,7 +2,7 @@
import { EventEmitter } from 'node:events';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { createCompatApiErrorResponse, createSseResponse } from './server.js';
import { createCompatApiErrorResponse, createSseResponse } from '../src/server.js';
afterEach(() => {
vi.useRealTimers();

View file

@ -10,6 +10,7 @@
"allowJs": false,
"checkJs": false,
"outDir": "dist",
"rootDir": "src",
"declaration": true,
"sourceMap": true,
"isolatedModules": true,
@ -20,8 +21,8 @@
"types": ["node", "vitest"]
},
"include": [
"**/*.ts",
"**/*.tsx"
"src/**/*.ts",
"src/**/*.tsx"
],
"exclude": ["node_modules", "dist"]
}

View file

@ -0,0 +1,15 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"rootDir": "."
},
"include": [
"sidecar/**/*.ts",
"src/**/*.ts"
],
"exclude": [
"node_modules",
"dist",
"tests"
]
}

View file

@ -0,0 +1,16 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": true,
"rootDir": "."
},
"include": [
"tests/**/*.ts",
"tests/**/*.tsx",
"src/**/*.ts"
],
"exclude": [
"node_modules",
"dist"
]
}

View file

@ -3,6 +3,6 @@ import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'node',
include: ['**/*.test.{ts,tsx,js,mjs,cjs}'],
include: ['tests/**/*.test.{ts,tsx,js,mjs,cjs}'],
},
});

33
apps/desktop/package.json Normal file
View file

@ -0,0 +1,33 @@
{
"name": "@open-design/desktop",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "./dist/main/index.js",
"files": [
"dist"
],
"exports": {
"./main": {
"types": "./dist/main/index.d.ts",
"default": "./dist/main/index.js"
}
},
"scripts": {
"build": "tsc -p tsconfig.json",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"@open-design/platform": "workspace:0.1.0",
"@open-design/sidecar": "workspace:0.1.0",
"@open-design/sidecar-proto": "workspace:0.1.0"
},
"devDependencies": {
"@types/node": "24.12.2",
"electron": "41.3.0",
"typescript": "6.0.3"
},
"engines": {
"node": "~24"
}
}

View file

@ -0,0 +1,154 @@
import { realpathSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { app } from "electron";
import {
APP_KEYS,
OPEN_DESIGN_SIDECAR_CONTRACT,
SIDECAR_ENV,
SIDECAR_MESSAGES,
normalizeDesktopSidecarMessage,
type DesktopClickInput,
type DesktopEvalInput,
type DesktopScreenshotInput,
type SidecarStamp,
type WebStatusSnapshot,
} from "@open-design/sidecar-proto";
import {
bootstrapSidecarRuntime,
createJsonIpcServer,
requestJsonIpc,
resolveAppIpcPath,
type JsonIpcServerHandle,
type SidecarRuntimeContext,
} from "@open-design/sidecar";
import { readProcessStamp } from "@open-design/platform";
import { createDesktopRuntime } from "./runtime.js";
const TOOLS_DEV_PARENT_PID_ENV = SIDECAR_ENV.TOOLS_DEV_PARENT_PID;
export type DesktopMainOptions = {
beforeShutdown?: () => Promise<void>;
discoverWebUrl?: () => Promise<string | null>;
};
function isDirectEntry(): boolean {
const entryPath = process.argv[1];
if (entryPath == null || entryPath.length === 0 || entryPath.startsWith("--")) return false;
try {
return realpathSync(entryPath) === realpathSync(fileURLToPath(import.meta.url));
} catch {
return false;
}
}
function isProcessAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
function attachParentMonitor(stop: () => Promise<void>): void {
const parentPid = Number(process.env[TOOLS_DEV_PARENT_PID_ENV]);
if (!Number.isInteger(parentPid) || parentPid <= 0) return;
const timer = setInterval(() => {
if (isProcessAlive(parentPid)) return;
clearInterval(timer);
void stop().finally(() => process.exit(0));
}, 1000);
timer.unref();
}
function createWebDiscovery(runtime: SidecarRuntimeContext<SidecarStamp>): () => Promise<string | null> {
return async () => {
const webIpc = resolveAppIpcPath({
app: APP_KEYS.WEB,
contract: OPEN_DESIGN_SIDECAR_CONTRACT,
namespace: runtime.namespace,
});
const web = await requestJsonIpc<WebStatusSnapshot>(webIpc, { type: SIDECAR_MESSAGES.STATUS }, { timeoutMs: 600 }).catch(() => null);
return web?.url ?? null;
};
}
export async function runDesktopMain(
runtime: SidecarRuntimeContext<SidecarStamp>,
options: DesktopMainOptions = {},
): Promise<void> {
await app.whenReady();
const desktop = await createDesktopRuntime({
discoverUrl: options.discoverWebUrl ?? createWebDiscovery(runtime),
});
let ipcServer: JsonIpcServerHandle | null = null;
let shuttingDown = false;
async function shutdown(): Promise<void> {
if (shuttingDown) return;
shuttingDown = true;
await options.beforeShutdown?.().catch((error: unknown) => {
console.error("desktop beforeShutdown failed", error);
});
await ipcServer?.close().catch(() => undefined);
await desktop.close().catch(() => undefined);
app.quit();
}
attachParentMonitor(shutdown);
ipcServer = await createJsonIpcServer({
socketPath: runtime.ipc,
handler: async (message: unknown) => {
const request = normalizeDesktopSidecarMessage(message);
switch (request.type) {
case SIDECAR_MESSAGES.STATUS:
return desktop.status();
case SIDECAR_MESSAGES.EVAL:
return await desktop.eval(request.input as DesktopEvalInput);
case SIDECAR_MESSAGES.SCREENSHOT:
return await desktop.screenshot(request.input as DesktopScreenshotInput);
case SIDECAR_MESSAGES.CONSOLE:
return desktop.console();
case SIDECAR_MESSAGES.CLICK:
return await desktop.click(request.input as DesktopClickInput);
case SIDECAR_MESSAGES.SHUTDOWN:
setImmediate(() => {
void shutdown().finally(() => process.exit(0));
});
return { accepted: true };
}
},
});
app.on("window-all-closed", () => {
void shutdown().finally(() => process.exit(0));
});
for (const signal of ["SIGINT", "SIGTERM"] as const) {
process.on(signal, () => {
void shutdown().finally(() => process.exit(0));
});
}
}
if (isDirectEntry()) {
const stamp = readProcessStamp(process.argv.slice(2), OPEN_DESIGN_SIDECAR_CONTRACT);
if (stamp == null) throw new Error("sidecar stamp is required");
const runtime = bootstrapSidecarRuntime(stamp, process.env, {
app: APP_KEYS.DESKTOP,
contract: OPEN_DESIGN_SIDECAR_CONTRACT,
});
void runDesktopMain(runtime).catch((error: unknown) => {
console.error(error instanceof Error ? error.stack || error.message : String(error));
process.exit(1);
});
}

View file

@ -0,0 +1,231 @@
import { mkdir, writeFile } from "node:fs/promises";
import { dirname, isAbsolute, resolve } from "node:path";
import { BrowserWindow } from "electron";
const PENDING_POLL_MS = 120;
const RUNNING_POLL_MS = 2000;
const MAX_CONSOLE_ENTRIES = 200;
export type DesktopEvalInput = {
expression: string;
};
export type DesktopEvalResult = {
error?: string;
ok: boolean;
value?: unknown;
};
export type DesktopScreenshotInput = {
path: string;
};
export type DesktopScreenshotResult = {
path: string;
};
export type DesktopConsoleEntry = {
level: string;
text: string;
timestamp: string;
};
export type DesktopConsoleResult = {
entries: DesktopConsoleEntry[];
};
export type DesktopClickInput = {
selector: string;
};
export type DesktopClickResult = {
clicked: boolean;
found: boolean;
};
export type DesktopStatusSnapshot = {
pid?: number;
state: "idle" | "running" | "unknown";
title?: string | null;
updatedAt?: string;
url?: string | null;
windowVisible?: boolean;
};
export type DesktopRuntime = {
close(): Promise<void>;
click(input: DesktopClickInput): Promise<DesktopClickResult>;
console(): DesktopConsoleResult;
eval(input: DesktopEvalInput): Promise<DesktopEvalResult>;
screenshot(input: DesktopScreenshotInput): Promise<DesktopScreenshotResult>;
status(): DesktopStatusSnapshot;
};
export type DesktopRuntimeOptions = {
discoverUrl(): Promise<string | null>;
};
function createPendingHtml(): string {
return `data:text/html;charset=utf-8,${encodeURIComponent(`<!doctype html>
<html>
<head>
<title>Open Design</title>
<style>
body {
align-items: center;
background: #05070d;
color: #f7f7fb;
display: flex;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
height: 100vh;
justify-content: center;
margin: 0;
}
main {
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 24px;
padding: 32px;
}
p { color: #aeb7d5; margin: 12px 0 0; }
</style>
</head>
<body>
<main>
<h1>Open Design</h1>
<p>Waiting for the web runtime URL</p>
</main>
</body>
</html>`)}`;
}
function normalizeScreenshotPath(filePath: string): string {
return isAbsolute(filePath) ? filePath : resolve(process.cwd(), filePath);
}
function mapConsoleLevel(level: number): string {
switch (level) {
case 0:
return "debug";
case 1:
return "info";
case 2:
return "warn";
case 3:
return "error";
default:
return "log";
}
}
export async function createDesktopRuntime(options: DesktopRuntimeOptions): Promise<DesktopRuntime> {
const consoleEntries: DesktopConsoleEntry[] = [];
const window = new BrowserWindow({
height: 900,
show: true,
title: "Open Design",
webPreferences: {
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
},
width: 1280,
});
let currentUrl: string | null = null;
let stopped = false;
let timer: NodeJS.Timeout | null = null;
(window.webContents as any).on("console-message", (event: { level?: number | string; message?: string }) => {
const level = typeof event.level === "number" ? mapConsoleLevel(event.level) : (event.level ?? "log");
consoleEntries.push({
level,
text: event.message ?? "",
timestamp: new Date().toISOString(),
});
if (consoleEntries.length > MAX_CONSOLE_ENTRIES) {
consoleEntries.splice(0, consoleEntries.length - MAX_CONSOLE_ENTRIES);
}
});
await window.loadURL(createPendingHtml());
const schedule = (delayMs: number) => {
if (stopped) return;
timer = setTimeout(() => {
void tick();
}, delayMs);
};
const tick = async () => {
if (stopped || window.isDestroyed()) return;
try {
const url = await options.discoverUrl();
if (url != null && url !== currentUrl) {
currentUrl = url;
await window.loadURL(url);
}
schedule(url == null ? PENDING_POLL_MS : RUNNING_POLL_MS);
} catch (error) {
console.error("desktop web discovery failed", error);
schedule(PENDING_POLL_MS);
}
};
void tick();
return {
async click(input) {
if (window.isDestroyed()) return { clicked: false, found: false };
const selector = JSON.stringify(input.selector);
return await window.webContents.executeJavaScript(
`(() => {
const element = document.querySelector(${selector});
if (!element) return { found: false, clicked: false };
if (typeof element.click === "function") element.click();
return { found: true, clicked: true };
})()`,
true,
);
},
async close() {
stopped = true;
if (timer != null) {
clearTimeout(timer);
timer = null;
}
if (!window.isDestroyed()) window.close();
},
console() {
return { entries: [...consoleEntries] };
},
async eval(input) {
if (window.isDestroyed()) return { error: "desktop window is destroyed", ok: false };
try {
const value = await window.webContents.executeJavaScript(input.expression, true);
return { ok: true, value };
} catch (error) {
return { error: error instanceof Error ? error.message : String(error), ok: false };
}
},
async screenshot(input) {
if (window.isDestroyed()) throw new Error("desktop window is destroyed");
const outputPath = normalizeScreenshotPath(input.path);
const image = await window.webContents.capturePage();
await mkdir(dirname(outputPath), { recursive: true });
await writeFile(outputPath, image.toPNG());
return { path: outputPath };
},
status() {
return {
pid: process.pid,
state: window.isDestroyed() ? "unknown" : "running",
title: window.isDestroyed() ? null : window.getTitle(),
updatedAt: new Date().toISOString(),
url: currentUrl,
windowVisible: !window.isDestroyed() && window.isVisible(),
};
},
};
}

View file

@ -0,0 +1,21 @@
{
"compilerOptions": {
"allowSyntheticDefaultImports": true,
"declaration": true,
"declarationMap": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"isolatedModules": true,
"lib": ["ES2024", "DOM"],
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "./dist",
"resolveJsonModule": true,
"rootDir": "./src",
"skipLibCheck": true,
"strict": true,
"target": "ES2024",
"types": ["node"]
},
"include": ["src/**/*.ts"]
}

5
apps/packaged/README.md Normal file
View file

@ -0,0 +1,5 @@
# apps/packaged
Minimal placeholder for the future packaged app assembly workstream.
No active runtime code, package manifest, or lifecycle script lives here yet.

View file

@ -7,7 +7,7 @@ import { ClientApp } from './client-app';
//
// For `output: 'export'` we return a single empty `slug` so Next.js emits
// one shell HTML at out/index.html; the daemon's SPA fallback (see
// apps/daemon/server.js) serves it for any unknown non-API path so deep links
// apps/daemon/src/server.ts) serves it for any unknown non-API path so deep links
// still hydrate to the right view. In dev we leave `dynamicParams` at its
// default (true) so `next dev` happily renders /projects/<id> directly.
export function generateStaticParams() {

View file

@ -1,6 +1,8 @@
import type { NextConfig } from 'next';
import { dirname, isAbsolute, relative } from 'node:path';
import { fileURLToPath } from 'node:url';
// Daemon port the local Express server binds to (see apps/daemon/cli.js). The
// Daemon port the local Express server binds to (see apps/daemon/src/cli.ts). The
// dev-all launcher overrides OD_PORT after probing for a free port; we read
// the same env so /api, /artifacts, and /frames always reach the right
// daemon instance during `next dev`.
@ -16,12 +18,31 @@ const DAEMON_ORIGIN = `http://127.0.0.1:${DAEMON_PORT}`;
// view.
const isProd = process.env.NODE_ENV !== 'development';
const WEB_ROOT = dirname(fileURLToPath(import.meta.url));
function resolveDevDistDir() {
const configured = process.env.OD_WEB_DIST_DIR;
if (!configured) return '.next';
return isAbsolute(configured) ? relative(WEB_ROOT, configured) || '.' : configured;
}
const DEV_DIST_DIR = resolveDevDistDir();
function resolveDevTsconfigPath() {
const configured = process.env.OD_WEB_TSCONFIG_PATH;
if (!configured) return undefined;
return isAbsolute(configured) ? relative(WEB_ROOT, configured) || 'tsconfig.json' : configured;
}
const DEV_TSCONFIG_PATH = resolveDevTsconfigPath();
const nextConfig: NextConfig = {
allowedDevOrigins: ['127.0.0.1'],
reactStrictMode: true,
...(DEV_TSCONFIG_PATH ? { typescript: { tsconfigPath: DEV_TSCONFIG_PATH } } : {}),
// Keep the bundle output predictable so the daemon's STATIC_DIR can point
// at it without any glob trickery.
distDir: isProd ? 'out' : '.next',
distDir: isProd ? 'out' : DEV_DIST_DIR,
...(isProd
? {
output: 'export' as const,

View file

@ -11,7 +11,10 @@
},
"dependencies": {
"@anthropic-ai/sdk": "^0.32.1",
"@open-design/contracts": "workspace:*",
"@open-design/contracts": "workspace:0.1.0",
"@open-design/platform": "workspace:0.1.0",
"@open-design/sidecar": "workspace:0.1.0",
"@open-design/sidecar-proto": "workspace:0.1.0",
"next": "^16.2.4",
"openai": "^6.35.0",
"react": "^18.3.1",

24
apps/web/sidecar/index.ts Normal file
View file

@ -0,0 +1,24 @@
import { APP_KEYS, OPEN_DESIGN_SIDECAR_CONTRACT } from "@open-design/sidecar-proto";
import { bootstrapSidecarRuntime } from "@open-design/sidecar";
import { readProcessStamp } from "@open-design/platform";
import { startWebSidecar } from "./server.js";
async function main(): Promise<void> {
const stamp = readProcessStamp(process.argv.slice(2), OPEN_DESIGN_SIDECAR_CONTRACT);
if (stamp == null) throw new Error("sidecar stamp is required");
const runtime = bootstrapSidecarRuntime(stamp, process.env, {
app: APP_KEYS.WEB,
contract: OPEN_DESIGN_SIDECAR_CONTRACT,
});
const server = await startWebSidecar(runtime);
process.stdout.write(`${JSON.stringify(await server.status(), null, 2)}\n`);
await server.waitUntilStopped();
}
void main().catch((error: unknown) => {
console.error(error instanceof Error ? error.stack || error.message : String(error));
process.exit(1);
});

192
apps/web/sidecar/server.ts Normal file
View file

@ -0,0 +1,192 @@
import { createServer as createHttpServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
import { readFileSync } from "node:fs";
import { readFile, rm, writeFile } from "node:fs/promises";
import { createRequire } from "node:module";
import type { AddressInfo } from "node:net";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import {
SIDECAR_ENV,
SIDECAR_MESSAGES,
normalizeWebSidecarMessage,
type SidecarStamp,
type WebStatusSnapshot,
} from "@open-design/sidecar-proto";
import {
createJsonIpcServer,
type JsonIpcServerHandle,
type SidecarRuntimeContext,
} from "@open-design/sidecar";
const HOST = "127.0.0.1";
const WEB_PORT_ENV = SIDECAR_ENV.WEB_PORT;
const TOOLS_DEV_PARENT_PID_ENV = SIDECAR_ENV.TOOLS_DEV_PARENT_PID;
const require = createRequire(import.meta.url);
const createNextServer = require("next") as (options: { dev: boolean; dir: string }) => {
close?: () => Promise<void>;
getRequestHandler(): (request: IncomingMessage, response: ServerResponse) => Promise<void>;
prepare(): Promise<void>;
};
export type WebSidecarHandle = {
status(): Promise<WebStatusSnapshot>;
stop(): Promise<void>;
waitUntilStopped(): Promise<void>;
};
function resolveWebRoot(): string {
let current = dirname(fileURLToPath(import.meta.url));
for (let depth = 0; depth < 8; depth += 1) {
try {
const packageJson = JSON.parse(readFileSync(join(current, "package.json"), "utf8")) as { name?: unknown };
if (packageJson.name === "@open-design/web") return current;
} catch {
// Keep walking until the package root is found. This must work from both
// sidecar/*.ts under tsx and dist/sidecar/*.js in packaged installs.
}
const parent = dirname(current);
if (parent === current) break;
current = parent;
}
throw new Error("failed to resolve @open-design/web package root");
}
function parsePort(value: string | undefined): number {
if (value == null || value.trim().length === 0) return 0;
const port = Number(value);
if (!Number.isInteger(port) || port <= 0 || port > 65535) {
throw new Error(`${WEB_PORT_ENV} must be an integer between 1 and 65535`);
}
return port;
}
async function prepareNextApp(app: { prepare(): Promise<void> }, dir: string): Promise<void> {
const nextEnvPath = join(dir, "next-env.d.ts");
const previousNextEnv = await readFile(nextEnvPath, "utf8").catch(() => null);
await app.prepare();
if (previousNextEnv == null) {
await rm(nextEnvPath, { force: true }).catch(() => undefined);
return;
}
await writeFile(nextEnvPath, previousNextEnv, "utf8").catch(() => undefined);
}
async function listen(server: Server, port: number): Promise<number> {
await new Promise<void>((resolveListen, rejectListen) => {
server.once("error", rejectListen);
server.listen({ host: HOST, port }, () => {
server.off("error", rejectListen);
resolveListen();
});
});
const address = server.address() as AddressInfo | string | null;
if (address == null || typeof address === "string") {
throw new Error("failed to resolve Next.js server address");
}
return address.port;
}
async function closeHttpServer(server: Server): Promise<void> {
if (!server.listening) return;
await new Promise<void>((resolveClose, rejectClose) => {
server.close((error) => (error == null ? resolveClose() : rejectClose(error)));
});
}
function isProcessAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
function attachParentMonitor(stop: () => Promise<void>): void {
const parentPid = Number(process.env[TOOLS_DEV_PARENT_PID_ENV]);
if (!Number.isInteger(parentPid) || parentPid <= 0) return;
const timer = setInterval(() => {
if (isProcessAlive(parentPid)) return;
clearInterval(timer);
void stop().finally(() => process.exit(0));
}, 1000);
timer.unref();
}
export async function startWebSidecar(runtime: SidecarRuntimeContext<SidecarStamp>): Promise<WebSidecarHandle> {
const dir = resolveWebRoot();
const app = createNextServer({ dev: runtime.mode === "dev", dir });
await prepareNextApp(app, dir);
const handleRequest = app.getRequestHandler();
const httpServer = createHttpServer((request, response) => {
void handleRequest(request, response).catch((error: unknown) => {
response.statusCode = 500;
response.end(error instanceof Error ? error.message : String(error));
});
});
const port = await listen(httpServer, parsePort(process.env[WEB_PORT_ENV]));
const state: WebStatusSnapshot = {
pid: process.pid,
state: "running",
updatedAt: new Date().toISOString(),
url: `http://${HOST}:${port}`,
};
let ipcServer: JsonIpcServerHandle | null = null;
let stopped = false;
let resolveStopped!: () => void;
const stoppedPromise = new Promise<void>((resolveStop) => {
resolveStopped = resolveStop;
});
async function stop(): Promise<void> {
if (stopped) return;
stopped = true;
state.state = "stopped";
state.updatedAt = new Date().toISOString();
await ipcServer?.close().catch(() => undefined);
await closeHttpServer(httpServer).catch(() => undefined);
await (app as unknown as { close?: () => Promise<void> }).close?.().catch(() => undefined);
resolveStopped();
}
attachParentMonitor(stop);
ipcServer = await createJsonIpcServer({
socketPath: runtime.ipc,
handler: async (message: unknown) => {
const request = normalizeWebSidecarMessage(message);
switch (request.type) {
case SIDECAR_MESSAGES.STATUS:
return { ...state };
case SIDECAR_MESSAGES.SHUTDOWN:
setImmediate(() => {
void stop().finally(() => process.exit(0));
});
return { accepted: true };
}
},
});
for (const signal of ["SIGINT", "SIGTERM"] as const) {
process.on(signal, () => {
void stop().finally(() => process.exit(0));
});
}
return {
async status() {
return { ...state };
},
stop,
waitUntilStopped() {
return stoppedPromise;
},
};
}

View file

@ -168,7 +168,7 @@ export async function streamViaDaemon({
}
}
// Translate a raw `agent` SSE payload (what apps/daemon/claude-stream.js emits)
// Translate a raw `agent` SSE payload (what apps/daemon/src/claude-stream.ts emits)
// into the UI's AgentEvent union. Keep this liberal — unknown types just
// return null so the UI ignores them instead of rendering garbage.
function translateAgentEvent(data: DaemonAgentPayload): AgentEvent | null {

View file

@ -35,6 +35,7 @@
"next-env.d.ts",
"next.config.ts",
"app/**/*",
"sidecar/**/*",
"src/**/*",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",

View file

@ -61,7 +61,7 @@ to the latest hashes:
```bash
curl -sL $(npm view getdesign dist.tarball) -o /tmp/getdesign.tgz
tar -xzf /tmp/getdesign.tgz -C /tmp
node scripts/sync-design-systems.mjs # planned helper — see roadmap
node --experimental-strip-types scripts/sync-design-systems.ts
```
For now, the original importer lives at the top of the

View file

@ -176,7 +176,7 @@ The adapter declares which strategy to use via `capabilities().nativeSkillLoadin
### 5.7 GitHub Copilot CLI
- Invocation: `copilot -p "<prompt>" --allow-all-tools --output-format json --add-dir <skills> --add-dir <design-systems>`. `--allow-all-tools` is mandatory in non-interactive mode — without it the CLI blocks waiting for human approval on every tool call. Unlike Codex (where `exec` is a dedicated headless subcommand with auto-approve baked in) or Claude Code (which inherits its permission policy from `~/.claude/settings.json`), Copilot's `-p` mode always prompts unless this flag is passed explicitly. `--add-dir` (repeatable) widens the path-level sandbox so Copilot can read skill seeds and design-system specs that live outside the project cwd.
- Streaming: `--output-format json` emits JSONL with the same expressive shape as Claude Code's stream-json (`assistant.reasoning_delta`, `assistant.message_delta`, `tool.execution_start/complete`, `result`). `apps/daemon/copilot-stream.js` maps these onto the same UI events as `claude-stream.js`.
- Streaming: `--output-format json` emits JSONL with the same expressive shape as Claude Code's stream-json (`assistant.reasoning_delta`, `assistant.message_delta`, `tool.execution_start/complete`, `result`). `apps/daemon/src/copilot-stream.ts` maps these onto the same UI events as `claude-stream.ts`.
- Skill loading: prompt injection only. Github Copilot's tool catalog includes a `skill` tool — native format worth reverse-engineering later.
- Surgical edits: dedicated `edit` tool.
- Detection assumes Copilot is already authenticated, via one of: `copilot login` (subcommand, OAuth device flow), the interactive `/login` slash command inside `copilot` with no args.
@ -222,7 +222,7 @@ The user explicitly opts in to fallback — we don't silently switch, because a
First run:
```
$ pnpm dev:all
$ pnpm tools-dev run web
[od] daemon starting on :7456
[od] detecting agents…
[od] ✓ claude-code v0.6.3 (auth: ok, skills dir linked)

View file

@ -31,7 +31,7 @@ OD is a web app plus a local daemon. The split means the same UI can run in thre
└────────────────────────────────────────────────────┘
```
One `pnpm dev:all` starts both the Next.js app and the daemon. Zero config. No accounts.
One `pnpm tools-dev run web` starts both the Next.js app and the daemon. `pnpm tools-dev` adds the desktop shell. Zero config. No accounts.
### Topology B — Web on Vercel + daemon on user's machine
@ -276,7 +276,7 @@ Full schema in [`schemas/protocol.md`](schemas/protocol.md) (TODO: write).
### Local
```sh
pnpm install
pnpm dev:all # starts daemon on :7456, next on :3000
pnpm tools-dev run web # starts daemon + web foreground loop
```
When a reverse proxy sits in front of the daemon, `/api/*` includes SSE streams and must stay unbuffered. The daemon sends `Cache-Control: no-cache, no-transform` and `X-Accel-Buffering: no`, and also emits SSE comment keepalives, but nginx can still break chunked streams if gzip is enabled. For nginx, set `proxy_buffering off;`, `gzip off;`, and long `proxy_read_timeout` / `proxy_send_timeout` values on the API location. Otherwise browsers can report `net::ERR_INCOMPLETE_CHUNKED_ENCODING 200 (OK)` on long generations.

View file

@ -83,7 +83,7 @@ Phased plan from "spec-only today" to "usable MVP" to "published v1." All estima
### MVP exit criteria
1. `corepack enable && pnpm install && pnpm dev:all` works on clean macOS and Linux with Node 24.
1. `corepack enable && pnpm install && pnpm tools-dev run web` works on clean macOS and Linux with Node 24.
2. With Claude Code installed: prototype + deck generation works end-to-end.
3. Without Claude Code installed: API-fallback produces prototypes (not decks — guizang-ppt-skill needs native skill loading).
4. A user can drop a DESIGN.md into the project root and subsequent generations respect it.

View file

@ -29,7 +29,7 @@ Other docs:
| # | Bet | [Anthropic Claude Design][cd] | [Open CoDesign][ocod] | OD |
|---|---|---|---|---|
| 1 | Where the product runs | claude.ai only | Local Electron app | **Next.js web app + local daemon** — `pnpm dev:all`, Vercel web deploy, or single-process daemon serving the built web app |
| 1 | Where the product runs | claude.ai only | Local Electron app | **Next.js web app + local daemon + desktop loop** — `pnpm tools-dev`, Vercel web deploy |
| 2 | Who owns the agent loop | Anthropic, closed | [Open CoDesign][ocod] itself, via [`pi-ai`][piai] | **The user's existing code agent CLI** (Claude Code, Codex, Cursor Agent, Gemini CLI, OpenCode, OpenClaw); direct Anthropic API as fallback |
| 3 | What "design skills" are | Proprietary internal tools | TypeScript modules baked into the app | **File-based skills** that follow Claude Code's `SKILL.md` spec — forkable, versionable, shareable, installable by symlink |
| 4 | How design systems are authored | Implicit in prompt | N/A | **`DESIGN.md` files** following the [awesome-claude-design][acd] 9-section schema |
@ -126,7 +126,7 @@ In short: Claude Design is a product; OD is a **substrate**.
## 9. Success criteria for v1
- One developer can `git clone && corepack enable && pnpm install && pnpm dev:all`, point at their Claude Code install, and produce a prototype in under 5 minutes.
- One developer can `git clone && corepack enable && pnpm install && pnpm tools-dev run web`, point at their Claude Code install, and produce a prototype in under 5 minutes.
- A third party can author a skill in a separate git repo, publish it, and have a user install it by running `od skill add <git-url>` without touching OD's source.
- A design system author can write a `DESIGN.md`, point OD at it, and have the style propagate across prototype / deck / template outputs.
- Deploying to Vercel with a local daemon works end-to-end (the daemon is reachable via localhost tunnel or a user-provided URL).

View file

@ -5,7 +5,7 @@
"type": "module",
"scripts": {
"test": "vitest run -c vitest.config.ts",
"typecheck": "tsc -p tsconfig.json --noEmit",
"typecheck": "tsc -p tsconfig.json --noEmit && tsc -p ../scripts/tsconfig.json --noEmit",
"test:ui:clean": "node --experimental-strip-types scripts/reset-artifacts.ts",
"test:ui": "corepack pnpm run test:ui:clean && playwright test -c playwright.config.ts",
"test:ui:headed": "corepack pnpm run test:ui:clean && playwright test -c playwright.config.ts --headed",

View file

@ -1,15 +1,8 @@
import { defineConfig, devices } from '@playwright/test';
import { resolveDevPorts } from '../scripts/resolve-dev-ports.ts';
const desiredDaemonPort = Number(process.env.OD_PORT) || 17_456;
const desiredNextPort = Number(process.env.NEXT_PORT) || 17_573;
const { daemonPort, appPort: nextPort } = await resolveDevPorts({
daemonStart: desiredDaemonPort,
appStart: desiredNextPort,
appLabel: 'next',
searchRange: 200,
});
const baseURL = `http://127.0.0.1:${nextPort}`;
const daemonPort = Number(process.env.OD_PORT) || 17_456;
const webPort = Number(process.env.OD_WEB_PORT) || 17_573;
const baseURL = `http://127.0.0.1:${webPort}`;
export default defineConfig({
testDir: './specs',
@ -43,10 +36,9 @@ export default defineConfig({
webServer: {
command:
`OD_DATA_DIR=e2e/.od-data ` +
`OD_PORT=${daemonPort} OD_PORT_STRICT=1 ` +
`NEXT_PORT=${nextPort} NEXT_PORT_STRICT=1 corepack pnpm --dir .. run dev:all`,
`pnpm --dir .. tools-dev run web --daemon-port ${daemonPort} --web-port ${webPort}`,
url: baseURL,
reuseExistingServer: !process.env.CI,
reuseExistingServer: false,
timeout: 120_000,
},
projects: [

View file

@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
import { createClaudeStreamHandler } from '../../apps/daemon/claude-stream.js';
import { createCopilotStreamHandler } from '../../apps/daemon/copilot-stream.js';
import { createClaudeStreamHandler } from '../../apps/daemon/src/claude-stream.js';
import { createCopilotStreamHandler } from '../../apps/daemon/src/copilot-stream.js';
describe('structured agent stream fixtures', () => {
it('emits TodoWrite tool_use from Claude Code stream JSON', () => {

View file

@ -10,20 +10,19 @@
"od": "./apps/daemon/dist/cli.js"
},
"scripts": {
"daemon": "corepack pnpm --filter @open-design/daemon daemon",
"dev": "corepack pnpm --filter @open-design/web dev",
"dev:all": "node --experimental-strip-types scripts/dev-all.ts",
"build": "corepack pnpm --filter @open-design/web build",
"postinstall": "node ./scripts/postinstall.mjs",
"tools-dev": "pnpm exec tools-dev",
"build": "pnpm --filter @open-design/web build",
"check:residual-js": "node --experimental-strip-types scripts/check-residual-js.ts",
"preview": "corepack pnpm run build && corepack pnpm --filter @open-design/daemon daemon",
"test:e2e:live": "corepack pnpm --filter @open-design/e2e test:e2e:live",
"test": "corepack pnpm --filter @open-design/web test && corepack pnpm --filter @open-design/daemon test && corepack pnpm --filter @open-design/e2e test",
"test:ui:clean": "corepack pnpm --filter @open-design/e2e test:ui:clean",
"test:ui": "corepack pnpm --filter @open-design/e2e test:ui",
"test:ui:headed": "corepack pnpm --filter @open-design/e2e test:ui:headed",
"typecheck": "corepack pnpm --filter @open-design/contracts typecheck && corepack pnpm --filter @open-design/web typecheck && corepack pnpm --filter @open-design/daemon typecheck && corepack pnpm --filter @open-design/daemon build && corepack pnpm --filter @open-design/e2e exec tsc -p ../scripts/tsconfig.json --noEmit && corepack pnpm --filter @open-design/e2e typecheck && corepack pnpm run check:residual-js",
"start": "corepack pnpm run build && corepack pnpm --filter @open-design/daemon start",
"test:run": "corepack pnpm run test"
"test:e2e:live": "pnpm --filter @open-design/e2e test:e2e:live",
"test": "pnpm -r --workspace-concurrency=1 --if-present run test",
"test:ui:clean": "pnpm --filter @open-design/e2e test:ui:clean",
"test:ui": "pnpm --filter @open-design/e2e test:ui",
"test:ui:headed": "pnpm --filter @open-design/e2e test:ui:headed",
"typecheck": "pnpm -r --workspace-concurrency=1 --if-present run typecheck && pnpm --filter @open-design/daemon build && pnpm check:residual-js"
},
"devDependencies": {
"@open-design/tools-dev": "workspace:0.1.0"
},
"engines": {
"node": "~24",
@ -31,7 +30,9 @@
},
"pnpm": {
"onlyBuiltDependencies": [
"better-sqlite3"
"better-sqlite3",
"electron",
"esbuild"
]
}
}

34
packages/AGENTS.md Normal file
View file

@ -0,0 +1,34 @@
# packages/AGENTS.md
Follow the root `AGENTS.md` first. This file only records module-level boundaries for `packages/`.
## Package responsibilities
- `packages/contracts`: web/daemon app contract layer. Keep it pure TypeScript; it must not depend on Next.js, Express, Node filesystem/process APIs, browser APIs, SQLite, daemon internals, or the sidecar control-plane protocol.
- `packages/sidecar-proto`: Open Design sidecar business protocol. Owns app/mode/source constants, namespace validation, stamp descriptor/fields/flags, IPC message schema, status shapes, error semantics, and default product path constants.
- `packages/sidecar`: generic sidecar runtime primitives. Includes bootstrap, IPC transport, path/runtime resolution, launch env, and JSON runtime file helpers; it must not hard-code Open Design app keys or IPC business messages.
- `packages/platform`: generic OS process primitives. Includes stamp serialization, command parsing, and process matching/search; it must consume the `sidecar-proto` descriptor and must not hard-code `--od-stamp-*` details.
## Removed directories
- `packages/shared` has been removed; do not restore it.
- For new shared types, choose the boundary first: web/daemon app DTOs go in `contracts`; sidecar control-plane protocol goes in `sidecar-proto`; generic runtime code goes in `sidecar`; generic OS/process code goes in `platform`.
## Boundary checklist
- Do not move runtime validation/schema enforcement into `contracts` prematurely; current contracts define the typed target shape only.
- Do not let app packages depend directly on sidecar control-plane details.
- Do not hard-code Open Design app/source/mode constants in `sidecar` or `platform`.
- Keep stamp fields limited to five: `app`, `mode`, `namespace`, `ipc`, and `source`.
## Common package commands
```bash
pnpm --filter @open-design/contracts typecheck
pnpm --filter @open-design/sidecar-proto typecheck
pnpm --filter @open-design/sidecar-proto test
pnpm --filter @open-design/sidecar typecheck
pnpm --filter @open-design/sidecar test
pnpm --filter @open-design/platform typecheck
pnpm --filter @open-design/platform test
```

View file

@ -0,0 +1,11 @@
import { build } from "esbuild";
await build({
bundle: true,
entryPoints: ["./src/index.ts"],
format: "esm",
outfile: "./dist/index.mjs",
packages: "external",
platform: "node",
target: "node24",
});

View file

@ -0,0 +1,31 @@
{
"name": "@open-design/platform",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "./dist/index.mjs",
"types": "./dist/index.d.ts",
"files": [
"dist"
],
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.mjs"
}
},
"scripts": {
"build": "node ./esbuild.config.mjs && tsc -p tsconfig.json --emitDeclarationOnly",
"test": "vitest run",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"devDependencies": {
"@types/node": "24.12.2",
"esbuild": "0.27.7",
"vitest": "^2.1.8",
"typescript": "6.0.3"
},
"engines": {
"node": "~24"
}
}

View file

@ -0,0 +1,82 @@
import { describe, expect, it } from "vitest";
import {
createProcessStampArgs,
matchesStampedProcess,
readProcessStampFromCommand,
type ProcessStampContract,
} from "./index.js";
type FakeStamp = {
app: "api" | "ui";
ipc: string;
mode: "dev" | "runtime";
namespace: string;
source: "tool" | "pack";
};
const fakeContract: ProcessStampContract<FakeStamp> = {
stampFields: ["app", "mode", "namespace", "ipc", "source"],
stampFlags: {
app: "--fake-app",
ipc: "--fake-ipc",
mode: "--fake-mode",
namespace: "--fake-namespace",
source: "--fake-source",
},
normalizeStamp(input) {
const value = input as Partial<FakeStamp>;
if (value.app !== "api" && value.app !== "ui") throw new Error("invalid app");
if (value.mode !== "dev" && value.mode !== "runtime") throw new Error("invalid mode");
if (typeof value.namespace !== "string" || value.namespace.length === 0) throw new Error("invalid namespace");
if (typeof value.ipc !== "string" || value.ipc.length === 0) throw new Error("invalid ipc");
if (value.source !== "tool" && value.source !== "pack") throw new Error("invalid source");
return {
app: value.app,
ipc: value.ipc,
mode: value.mode,
namespace: value.namespace,
source: value.source,
};
},
normalizeStampCriteria(input = {}) {
const value = input as Partial<FakeStamp>;
return {
...(value.app == null ? {} : { app: value.app }),
...(value.ipc == null ? {} : { ipc: value.ipc }),
...(value.mode == null ? {} : { mode: value.mode }),
...(value.namespace == null ? {} : { namespace: value.namespace }),
...(value.source == null ? {} : { source: value.source }),
};
},
};
const stamp: FakeStamp = {
app: "ui",
ipc: "/tmp/fake-product/ipc/stamp-boundary-a/ui.sock",
mode: "dev",
namespace: "stamp-boundary-a",
source: "tool",
};
describe("generic process stamp primitives", () => {
it("serializes descriptor-defined stamp flags", () => {
const args = createProcessStampArgs(stamp, fakeContract);
expect(args).toHaveLength(5);
expect(args.join(" ")).toContain("--fake-app=ui");
expect(args.join(" ")).toContain("--fake-mode=dev");
expect(args.join(" ")).toContain("--fake-namespace=stamp-boundary-a");
expect(args.join(" ")).toContain("--fake-ipc=/tmp/fake-product/ipc/stamp-boundary-a/ui.sock");
expect(args.join(" ")).toContain("--fake-source=tool");
});
it("reads and matches stamped process commands using the descriptor", () => {
const command = ["node", "ui.js", ...createProcessStampArgs(stamp, fakeContract)].join(" ");
expect(readProcessStampFromCommand(command, fakeContract)).toEqual(stamp);
expect(matchesStampedProcess({ command }, { app: "ui", namespace: stamp.namespace, source: "tool" }, fakeContract)).toBe(true);
expect(matchesStampedProcess({ command }, { namespace: "stamp-boundary-b" }, fakeContract)).toBe(false);
expect(matchesStampedProcess({ command }, { source: "pack" }, fakeContract)).toBe(false);
});
});

View file

@ -0,0 +1,372 @@
import { execFile, spawn, type ChildProcess, type StdioOptions } from "node:child_process";
import { readFile } from "node:fs/promises";
import { setTimeout as sleep } from "node:timers/promises";
export type CommandInvocation = {
args: string[];
command: string;
};
export type ProcessStampShape = object;
export type ProcessStampField<TStamp extends ProcessStampShape> = Extract<keyof TStamp, string>;
export type ProcessStampContract<
TStamp extends ProcessStampShape,
TCriteria extends Partial<TStamp> = Partial<TStamp>,
> = {
normalizeStamp(input: unknown): TStamp;
normalizeStampCriteria(input?: unknown): TCriteria;
stampFields: readonly ProcessStampField<TStamp>[];
stampFlags: { readonly [K in ProcessStampField<TStamp>]: string };
};
export type CommandInvocationRequest = {
args?: string[];
command: string;
env?: NodeJS.ProcessEnv;
};
export type SpawnProcessRequest = CommandInvocationRequest & {
cwd?: string;
detached?: boolean;
logFd?: number | null;
};
export type ProcessSnapshot = {
command: string;
pid: number;
ppid: number;
};
export type StampedProcessMatchCriteria<TStamp extends ProcessStampShape> = Partial<TStamp>;
export type StopProcessesResult = {
alreadyStopped: boolean;
forcedPids: number[];
matchedPids: number[];
remainingPids: number[];
stoppedPids: number[];
};
export type HttpWaitOptions = {
timeoutMs?: number;
};
type WindowsProcessRecord = {
CommandLine?: string | null;
ParentProcessId?: number | string | null;
ProcessId?: number | string | null;
};
export function createProcessStampArgs<TStamp extends ProcessStampShape>(
stamp: TStamp,
contract: ProcessStampContract<TStamp>,
): string[] {
const normalized = contract.normalizeStamp(stamp);
return contract.stampFields.map((field) => {
const value = normalized[field];
if (typeof value !== "string") {
throw new Error(`process stamp field ${field} must normalize to a string`);
}
return `${contract.stampFlags[field]}=${value}`;
});
}
function commandArgs(command: string): string[] {
return command.trim().split(/\s+/).filter((part) => part.length > 0);
}
export function readFlagValue(args: readonly string[], flagName: string): string | null {
const inlinePrefix = `${flagName}=`;
for (let index = 0; index < args.length; index += 1) {
const argument = args[index];
if (argument === flagName) return args[index + 1] ?? null;
if (typeof argument === "string" && argument.startsWith(inlinePrefix)) {
return argument.slice(inlinePrefix.length);
}
}
return null;
}
export function readProcessStamp<TStamp extends ProcessStampShape>(
args: readonly string[],
contract: ProcessStampContract<TStamp>,
): TStamp | null {
try {
const input = Object.fromEntries(
contract.stampFields.map((field) => [field, readFlagValue(args, contract.stampFlags[field])]),
);
return contract.normalizeStamp(input);
} catch {
return null;
}
}
export function readProcessStampFromCommand<TStamp extends ProcessStampShape>(
command: string,
contract: ProcessStampContract<TStamp>,
): TStamp | null {
return readProcessStamp(commandArgs(command), contract);
}
export function matchesProcessStamp<TStamp extends ProcessStampShape, TCriteria extends Partial<TStamp> = Partial<TStamp>>(
stamp: TStamp,
criteria: TCriteria | undefined,
contract: ProcessStampContract<TStamp, TCriteria>,
): boolean {
const normalizedStamp = contract.normalizeStamp(stamp);
const normalizedCriteria = contract.normalizeStampCriteria(criteria ?? {});
return contract.stampFields.every((field) => {
const expected = normalizedCriteria[field as keyof TCriteria];
return expected == null || normalizedStamp[field] === expected;
});
}
export function matchesStampedProcess<TStamp extends ProcessStampShape, TCriteria extends Partial<TStamp> = Partial<TStamp>>(
processInfo: Pick<ProcessSnapshot, "command">,
criteria: TCriteria | undefined,
contract: ProcessStampContract<TStamp, TCriteria>,
): boolean {
const stamp = readProcessStampFromCommand(processInfo.command, contract);
return stamp != null && matchesProcessStamp(stamp, criteria, contract);
}
function errorCode(error: unknown): string | null {
if (typeof error !== "object" || error == null || !("code" in error)) return null;
const code = (error as { code?: unknown }).code;
return code == null ? null : String(code);
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function quoteWindowsCommandArg(value: string): string {
if (!/[\s"&<>|^]/.test(value)) return value;
return `"${value.replace(/"/g, '""')}"`;
}
export function createCommandInvocation({ args = [], command, env = process.env }: CommandInvocationRequest): CommandInvocation {
if (process.platform === "win32" && /\.(bat|cmd)$/i.test(command)) {
return {
args: ["/d", "/s", "/c", [command, ...args].map(quoteWindowsCommandArg).join(" ")],
command: env.ComSpec ?? process.env.ComSpec ?? "cmd.exe",
};
}
return { args, command };
}
export function createPackageManagerInvocation(args: string[], env: NodeJS.ProcessEnv = process.env): CommandInvocation {
const execPath = env.npm_execpath;
if (execPath) return { args: [execPath, ...args], command: process.execPath };
if (process.platform === "win32") {
return {
args: ["/d", "/s", "/c", ["pnpm", ...args].map(quoteWindowsCommandArg).join(" ")],
command: env.ComSpec ?? process.env.ComSpec ?? "cmd.exe",
};
}
return { args, command: "pnpm" };
}
function createLoggedStdio(logFd?: number | null): StdioOptions {
return logFd == null ? ["ignore", "ignore", "ignore"] : ["ignore", logFd, logFd];
}
async function waitForChildSpawn(child: ChildProcess): Promise<void> {
await new Promise<void>((resolveSpawn, rejectSpawn) => {
child.once("error", rejectSpawn);
child.once("spawn", resolveSpawn);
});
}
export async function spawnBackgroundProcess(request: SpawnProcessRequest): Promise<{ pid: number }> {
const invocation = createCommandInvocation(request);
const child = spawn(invocation.command, invocation.args, {
cwd: request.cwd,
detached: request.detached ?? true,
env: request.env,
stdio: createLoggedStdio(request.logFd),
windowsHide: process.platform === "win32",
});
await waitForChildSpawn(child);
if (child.pid == null) throw new Error(`failed to spawn background process: ${invocation.command}`);
child.unref();
return { pid: child.pid };
}
export async function spawnLoggedProcess(request: SpawnProcessRequest): Promise<ChildProcess> {
const invocation = createCommandInvocation(request);
const child = spawn(invocation.command, invocation.args, {
cwd: request.cwd,
detached: request.detached ?? false,
env: request.env,
stdio: createLoggedStdio(request.logFd),
windowsHide: process.platform === "win32",
});
await waitForChildSpawn(child);
if (child.pid == null) throw new Error(`failed to spawn process: ${invocation.command}`);
return child;
}
export function isProcessAlive(pid: number | null | undefined): boolean {
if (typeof pid !== "number") return false;
try {
process.kill(pid, 0);
return true;
} catch (error) {
if (errorCode(error) === "ESRCH") return false;
return true;
}
}
export async function waitForProcessExit(pid: number | null | undefined, timeoutMs = 5000): Promise<boolean> {
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
if (!isProcessAlive(pid)) return true;
await sleep(100);
}
return !isProcessAlive(pid);
}
function parsePsOutput(stdout: string): ProcessSnapshot[] {
return stdout
.split(/\r?\n/)
.map((line) => {
const match = line.match(/^\s*(\d+)\s+(\d+)\s+(.+)$/);
if (!match) return null;
return { pid: Number(match[1]), ppid: Number(match[2]), command: match[3] };
})
.filter((snapshot): snapshot is ProcessSnapshot => snapshot != null);
}
async function listPosixProcessSnapshots(): Promise<ProcessSnapshot[]> {
const stdout = await new Promise<string>((resolveList, rejectList) => {
execFile("ps", ["-axo", "pid=,ppid=,command="], { encoding: "utf8", maxBuffer: 8 * 1024 * 1024 }, (error, out) => {
if (error) rejectList(error);
else resolveList(out);
});
});
return parsePsOutput(stdout);
}
async function listWindowsProcessSnapshots(): Promise<ProcessSnapshot[]> {
const command = [
"$ErrorActionPreference = 'Stop'",
"Get-CimInstance Win32_Process | Select-Object ProcessId, ParentProcessId, CommandLine | ConvertTo-Json -Compress",
].join("; ");
const stdout = await new Promise<string>((resolveList, rejectList) => {
execFile("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", command], { encoding: "utf8", maxBuffer: 8 * 1024 * 1024 }, (error, out) => {
if (error) rejectList(error);
else resolveList(out);
});
});
const payload = stdout.trim();
if (!payload) return [];
const records = JSON.parse(payload) as WindowsProcessRecord | WindowsProcessRecord[];
return (Array.isArray(records) ? records : [records])
.map((record) => {
const pid = Number(record.ProcessId);
const ppid = Number(record.ParentProcessId);
const commandLine = record.CommandLine?.trim();
if (!commandLine || Number.isNaN(pid) || Number.isNaN(ppid)) return null;
return { command: commandLine, pid, ppid };
})
.filter((snapshot): snapshot is ProcessSnapshot => snapshot != null);
}
export async function listProcessSnapshots(): Promise<ProcessSnapshot[]> {
try {
return process.platform === "win32"
? await listWindowsProcessSnapshots()
: await listPosixProcessSnapshots();
} catch {
return [];
}
}
export function collectProcessTreePids(
processes: ProcessSnapshot[],
rootPids: Array<number | null | undefined>,
): number[] {
const queue = [...new Set(rootPids.filter((pid): pid is number => typeof pid === "number"))];
const visited = new Set<number>();
const childrenByParent = new Map<number, number[]>();
for (const processInfo of processes) {
const children = childrenByParent.get(processInfo.ppid) ?? [];
children.push(processInfo.pid);
childrenByParent.set(processInfo.ppid, children);
}
while (queue.length > 0) {
const pid = queue.shift();
if (pid == null || visited.has(pid)) continue;
visited.add(pid);
for (const childPid of childrenByParent.get(pid) ?? []) {
if (!visited.has(childPid)) queue.push(childPid);
}
}
return [...visited].sort((left, right) => right - left);
}
function signalProcesses(pids: number[], signal: NodeJS.Signals): void {
for (const pid of pids) {
try {
process.kill(pid, signal);
} catch (error) {
if (errorCode(error) !== "ESRCH") throw error;
}
}
}
async function waitForProcessesToExit(pids: number[], timeoutMs = 5000): Promise<number[]> {
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
const remaining = pids.filter(isProcessAlive);
if (remaining.length === 0) return [];
await sleep(100);
}
return pids.filter(isProcessAlive);
}
export async function stopProcesses(pids: Array<number | null | undefined>): Promise<StopProcessesResult> {
const uniquePids = [...new Set(pids)]
.filter((pid): pid is number => typeof pid === "number" && pid !== process.pid)
.sort((left, right) => right - left);
if (uniquePids.length === 0) {
return { alreadyStopped: true, forcedPids: [], matchedPids: [], remainingPids: [], stoppedPids: [] };
}
signalProcesses(uniquePids, "SIGTERM");
const remainingAfterTerm = await waitForProcessesToExit(uniquePids);
if (remainingAfterTerm.length === 0) {
return { alreadyStopped: false, forcedPids: [], matchedPids: uniquePids, remainingPids: [], stoppedPids: uniquePids };
}
signalProcesses(remainingAfterTerm, "SIGKILL");
const remainingAfterKill = await waitForProcessesToExit(remainingAfterTerm);
const stoppedPids = uniquePids.filter((pid) => !remainingAfterKill.includes(pid));
return { alreadyStopped: false, forcedPids: remainingAfterTerm, matchedPids: uniquePids, remainingPids: remainingAfterKill, stoppedPids };
}
export async function waitForHttpOk(url: string, { timeoutMs = 20000 }: HttpWaitOptions = {}): Promise<true> {
const startedAt = Date.now();
let lastError: Error | null = null;
while (Date.now() - startedAt < timeoutMs) {
try {
const response = await fetch(url, { cache: "no-store" });
if (response.ok) return true;
lastError = new Error(`HTTP ${response.status} from ${url}`);
} catch (error) {
lastError = new Error(errorMessage(error));
}
await sleep(150);
}
throw new Error(`timed out waiting for ${url}${lastError ? ` (${lastError.message})` : ""}`);
}
export async function readLogTail(filePath: string, maxLines = 80): Promise<string[]> {
try {
const payload = await readFile(filePath, "utf8");
return payload.split(/\r?\n/).filter((line) => line.length > 0).slice(-maxLines);
} catch {
return [];
}
}

View file

@ -0,0 +1,21 @@
{
"compilerOptions": {
"allowSyntheticDefaultImports": true,
"declaration": true,
"declarationMap": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"isolatedModules": true,
"lib": ["ES2024"],
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "./dist",
"resolveJsonModule": true,
"rootDir": "./src",
"skipLibCheck": true,
"strict": true,
"target": "ES2024",
"types": ["node"]
},
"include": ["src/**/*.ts"]
}

View file

@ -0,0 +1,13 @@
import { build } from "esbuild";
await build({
bundle: true,
entryPoints: ["./src/index.ts"],
format: "esm",
outbase: "./src",
outdir: "./dist",
outExtension: { ".js": ".mjs" },
packages: "external",
platform: "node",
target: "node24",
});

View file

@ -0,0 +1,31 @@
{
"name": "@open-design/sidecar-proto",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "./dist/index.mjs",
"types": "./dist/index.d.ts",
"files": [
"dist"
],
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.mjs"
}
},
"scripts": {
"build": "node ./esbuild.config.mjs && tsc -p tsconfig.json --emitDeclarationOnly",
"test": "vitest run",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"devDependencies": {
"@types/node": "24.12.2",
"esbuild": "0.27.7",
"typescript": "6.0.3",
"vitest": "^2.1.8"
},
"engines": {
"node": "~24"
}
}

View file

@ -0,0 +1,77 @@
import { describe, expect, it } from "vitest";
import {
APP_KEYS,
normalizeDaemonSidecarMessage,
normalizeDesktopSidecarMessage,
normalizeNamespace,
normalizeSidecarStamp,
OPEN_DESIGN_SIDECAR_CONTRACT,
SIDECAR_MESSAGES,
SIDECAR_SOURCES,
SIDECAR_STAMP_FIELDS,
STAMP_APP_FLAG,
STAMP_IPC_FLAG,
STAMP_MODE_FLAG,
STAMP_NAMESPACE_FLAG,
STAMP_SOURCE_FLAG,
} from "./index.js";
const validStamp = {
app: APP_KEYS.WEB,
ipc: "/tmp/open-design/ipc/contract-check/web.sock",
mode: "dev" as const,
namespace: "contract-check",
source: SIDECAR_SOURCES.TOOLS_DEV,
};
describe("open-design sidecar contract", () => {
it("exports the canonical five-field stamp descriptor", () => {
expect(SIDECAR_STAMP_FIELDS).toEqual(["app", "mode", "namespace", "ipc", "source"]);
expect(OPEN_DESIGN_SIDECAR_CONTRACT.stampFlags).toEqual({
app: STAMP_APP_FLAG,
ipc: STAMP_IPC_FLAG,
mode: STAMP_MODE_FLAG,
namespace: STAMP_NAMESPACE_FLAG,
source: STAMP_SOURCE_FLAG,
});
});
it("accepts the explicit namespace contract", () => {
expect(normalizeNamespace("contract-check_1.alpha")).toBe("contract-check_1.alpha");
});
it("rejects path-like or whitespace namespaces", () => {
expect(() => normalizeNamespace("../other")).toThrow();
expect(() => normalizeNamespace(" contract-check")).toThrow();
expect(() => normalizeNamespace("contract check")).toThrow();
});
it("accepts exactly app, mode, namespace, ipc, and source", () => {
expect(normalizeSidecarStamp(validStamp)).toEqual(validStamp);
});
it("rejects legacy or extra stamp fields", () => {
expect(() => normalizeSidecarStamp({ ...validStamp, runtimeToken: "legacy" })).toThrow();
expect(() => normalizeSidecarStamp({ ...validStamp, role: "web-sidecar" })).toThrow();
});
it("rejects non-contract sidecar sources", () => {
expect(() => normalizeSidecarStamp({ ...validStamp, source: "custom-script" })).toThrow();
});
it("validates daemon IPC messages", () => {
expect(normalizeDaemonSidecarMessage({ type: SIDECAR_MESSAGES.STATUS })).toEqual({ type: "status" });
expect(normalizeDaemonSidecarMessage({ type: SIDECAR_MESSAGES.SHUTDOWN })).toEqual({ type: "shutdown" });
expect(() => normalizeDaemonSidecarMessage({ input: {}, type: SIDECAR_MESSAGES.EVAL })).toThrow();
});
it("validates desktop IPC message inputs", () => {
expect(normalizeDesktopSidecarMessage({ input: { expression: "location.href" }, type: SIDECAR_MESSAGES.EVAL })).toEqual({
input: { expression: "location.href" },
type: "eval",
});
expect(() => normalizeDesktopSidecarMessage({ input: { expression: 42 }, type: SIDECAR_MESSAGES.EVAL })).toThrow();
expect(() => normalizeDesktopSidecarMessage({ input: { selector: "" }, type: SIDECAR_MESSAGES.CLICK })).toThrow();
});
});

View file

@ -0,0 +1,403 @@
export const APP_KEYS = Object.freeze({
DAEMON: "daemon",
DESKTOP: "desktop",
WEB: "web",
} as const);
export type AppKey = (typeof APP_KEYS)[keyof typeof APP_KEYS];
export const SIDECAR_MODES = Object.freeze({
DEV: "dev",
RUNTIME: "runtime",
} as const);
export type SidecarMode = (typeof SIDECAR_MODES)[keyof typeof SIDECAR_MODES];
export const SIDECAR_SOURCES = Object.freeze({
PACKAGED: "packaged",
TOOLS_DEV: "tools-dev",
TOOLS_PACK: "tools-pack",
} as const);
export type SidecarSource = (typeof SIDECAR_SOURCES)[keyof typeof SIDECAR_SOURCES];
export const SIDECAR_ENV = Object.freeze({
BASE: "OD_SIDECAR_BASE",
DAEMON_PORT: "OD_PORT",
IPC_BASE: "OD_SIDECAR_IPC_BASE",
IPC_PATH: "OD_SIDECAR_IPC_PATH",
NAMESPACE: "OD_SIDECAR_NAMESPACE",
SOURCE: "OD_SIDECAR_SOURCE",
TOOLS_DEV_PARENT_PID: "OD_TOOLS_DEV_PARENT_PID",
WEB_DIST_DIR: "OD_WEB_DIST_DIR",
WEB_PORT: "OD_WEB_PORT",
WEB_TSCONFIG_PATH: "OD_WEB_TSCONFIG_PATH",
} as const);
export const SIDECAR_RUNTIME_ENV = Object.freeze({
base: SIDECAR_ENV.BASE,
ipcBase: SIDECAR_ENV.IPC_BASE,
ipcPath: SIDECAR_ENV.IPC_PATH,
namespace: SIDECAR_ENV.NAMESPACE,
source: SIDECAR_ENV.SOURCE,
} as const);
export const SIDECAR_STAMP_FLAGS = Object.freeze({
app: "--od-stamp-app",
ipc: "--od-stamp-ipc",
mode: "--od-stamp-mode",
namespace: "--od-stamp-namespace",
source: "--od-stamp-source",
} as const);
export const STAMP_APP_FLAG = SIDECAR_STAMP_FLAGS.app;
export const STAMP_IPC_FLAG = SIDECAR_STAMP_FLAGS.ipc;
export const STAMP_MODE_FLAG = SIDECAR_STAMP_FLAGS.mode;
export const STAMP_NAMESPACE_FLAG = SIDECAR_STAMP_FLAGS.namespace;
export const STAMP_SOURCE_FLAG = SIDECAR_STAMP_FLAGS.source;
export const SIDECAR_STAMP_FIELDS = ["app", "mode", "namespace", "ipc", "source"] as const;
export const SIDECAR_DEFAULTS = Object.freeze({
host: "127.0.0.1",
ipcBase: "/tmp/open-design/ipc",
namespace: "default",
projectTmpDirName: ".tmp",
windowsPipePrefix: "open-design",
} as const);
export const SIDECAR_MESSAGES = Object.freeze({
CLICK: "click",
CONSOLE: "console",
EVAL: "eval",
SCREENSHOT: "screenshot",
SHUTDOWN: "shutdown",
STATUS: "status",
} as const);
export const SIDECAR_ERROR_CODES = Object.freeze({
INVALID_MESSAGE: "SIDECAR_INVALID_MESSAGE",
UNKNOWN_MESSAGE: "SIDECAR_UNKNOWN_MESSAGE",
} as const);
export type SidecarErrorCode = (typeof SIDECAR_ERROR_CODES)[keyof typeof SIDECAR_ERROR_CODES];
export class SidecarContractError extends Error {
readonly code: SidecarErrorCode;
constructor(code: SidecarErrorCode, message: string) {
super(message);
this.name = "SidecarContractError";
this.code = code;
}
}
export type ServiceRuntimeState = "idle" | "running" | "starting" | "stopped" | "unknown";
export type DaemonStatusSnapshot = {
pid?: number | null;
state: ServiceRuntimeState;
updatedAt?: string;
url: string | null;
};
export type WebStatusSnapshot = {
pid?: number | null;
state: ServiceRuntimeState;
updatedAt?: string;
url: string | null;
};
export type DesktopRuntimeState = "idle" | "running" | "unknown";
export type DesktopStatusSnapshot = {
pid?: number | null;
state: DesktopRuntimeState;
title?: string | null;
updatedAt?: string;
url?: string | null;
windowVisible?: boolean;
};
export type DesktopEvalInput = {
expression: string;
};
export type DesktopEvalResult = {
error?: string;
ok: boolean;
value?: unknown;
};
export type DesktopScreenshotInput = {
path: string;
};
export type DesktopScreenshotResult = {
path: string;
};
export type DesktopConsoleEntry = {
level: string;
text: string;
timestamp: string;
};
export type DesktopConsoleResult = {
entries: DesktopConsoleEntry[];
};
export type DesktopClickInput = {
selector: string;
};
export type DesktopClickResult = {
clicked: boolean;
found: boolean;
};
export type SidecarStatusMessage = { type: typeof SIDECAR_MESSAGES.STATUS };
export type SidecarShutdownMessage = { type: typeof SIDECAR_MESSAGES.SHUTDOWN };
export type DesktopEvalMessage = { input: DesktopEvalInput; type: typeof SIDECAR_MESSAGES.EVAL };
export type DesktopScreenshotMessage = { input: DesktopScreenshotInput; type: typeof SIDECAR_MESSAGES.SCREENSHOT };
export type DesktopConsoleMessage = { type: typeof SIDECAR_MESSAGES.CONSOLE };
export type DesktopClickMessage = { input: DesktopClickInput; type: typeof SIDECAR_MESSAGES.CLICK };
export type DaemonSidecarMessage = SidecarStatusMessage | SidecarShutdownMessage;
export type WebSidecarMessage = SidecarStatusMessage | SidecarShutdownMessage;
export type DesktopSidecarMessage =
| SidecarStatusMessage
| SidecarShutdownMessage
| DesktopEvalMessage
| DesktopScreenshotMessage
| DesktopConsoleMessage
| DesktopClickMessage;
export type ShutdownResult = {
accepted: true;
};
export type SidecarStamp = {
app: AppKey;
ipc: string;
mode: SidecarMode;
namespace: string;
source: SidecarSource;
};
export type SidecarStampInput = Partial<Record<(typeof SIDECAR_STAMP_FIELDS)[number], unknown>>;
export type SidecarStampCriteria = Partial<SidecarStamp>;
export type OpenDesignSidecarContract = {
appKeys: typeof APP_KEYS;
defaults: typeof SIDECAR_DEFAULTS;
env: typeof SIDECAR_RUNTIME_ENV;
errorCodes: typeof SIDECAR_ERROR_CODES;
messages: typeof SIDECAR_MESSAGES;
modes: typeof SIDECAR_MODES;
normalizeApp: typeof normalizeAppKey;
normalizeNamespace: typeof normalizeNamespace;
normalizeSource: typeof normalizeSidecarSource;
normalizeStamp: typeof normalizeSidecarStamp;
normalizeStampCriteria: typeof normalizeSidecarStampCriteria;
sources: typeof SIDECAR_SOURCES;
stampFields: typeof SIDECAR_STAMP_FIELDS;
stampFlags: typeof SIDECAR_STAMP_FLAGS;
};
function assertObject(value: unknown, label: string): Record<string, unknown> {
if (typeof value !== "object" || value == null || Array.isArray(value)) {
throw new Error(`${label} must be an object`);
}
return value as Record<string, unknown>;
}
function assertKnownKeys(value: Record<string, unknown>, allowed: readonly string[], label: string): void {
const allowedSet = new Set<string>(allowed);
const unexpected = Object.keys(value).filter((key) => !allowedSet.has(key));
if (unexpected.length > 0) {
throw new Error(`${label} contains unsupported fields: ${unexpected.join(", ")}`);
}
}
function normalizeNonEmptyString(value: unknown, label: string): string {
if (typeof value !== "string") throw new Error(`${label} must be a string`);
if (value.length === 0) throw new Error(`${label} must not be empty`);
return value;
}
export function normalizeNamespace(namespace: unknown): string {
if (typeof namespace !== "string") throw new Error("namespace must be a string");
const value = namespace.trim();
if (value.length === 0) throw new Error("namespace must not be empty");
if (value !== namespace) throw new Error("namespace must not contain leading or trailing whitespace");
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(value)) {
throw new Error(`namespace contains unsupported characters: ${value}`);
}
if (/[\\/]/.test(value)) throw new Error(`namespace must not contain path separators: ${value}`);
return value;
}
export function isSidecarMode(value: unknown): value is SidecarMode {
return Object.values(SIDECAR_MODES).includes(value as SidecarMode);
}
export function normalizeSidecarMode(mode: unknown): SidecarMode {
if (!isSidecarMode(mode)) {
throw new Error("sidecar mode must be dev or runtime");
}
return mode;
}
export function isAppKey(value: unknown): value is AppKey {
return Object.values(APP_KEYS).includes(value as AppKey);
}
export function normalizeAppKey(app: unknown): AppKey {
if (!isAppKey(app)) throw new Error(`unsupported sidecar app: ${String(app)}`);
return app;
}
export function isSidecarSource(value: unknown): value is SidecarSource {
return Object.values(SIDECAR_SOURCES).includes(value as SidecarSource);
}
export function normalizeSidecarSource(source: unknown): SidecarSource {
if (!isSidecarSource(source)) {
throw new Error(`unsupported sidecar source: ${String(source)}`);
}
return source;
}
export function isWindowsNamedPipePath(value: unknown): boolean {
return typeof value === "string" && value.startsWith("\\\\.\\pipe\\");
}
export function normalizeIpcPath(ipc: unknown): string {
if (typeof ipc !== "string") throw new Error("sidecar ipc path must be a string");
if (ipc.length === 0) throw new Error("sidecar ipc path must not be empty");
if (ipc.trim() !== ipc) throw new Error("sidecar ipc path must not contain leading or trailing whitespace");
if (ipc.includes("\0")) throw new Error("sidecar ipc path must not contain null bytes");
if (isWindowsNamedPipePath(ipc)) return ipc;
if (!ipc.startsWith("/") && !/^[A-Za-z]:[\\/]/.test(ipc)) {
throw new Error(`sidecar ipc path must be absolute: ${ipc}`);
}
return ipc;
}
function assertKnownStampKeys(value: Record<string, unknown>, label: string): void {
assertKnownKeys(value, SIDECAR_STAMP_FIELDS, label);
}
export function normalizeSidecarStamp(input: unknown): SidecarStamp {
const value = assertObject(input, "sidecar stamp");
assertKnownStampKeys(value, "sidecar stamp");
return {
app: normalizeAppKey(value.app),
ipc: normalizeIpcPath(value.ipc),
mode: normalizeSidecarMode(value.mode),
namespace: normalizeNamespace(value.namespace),
source: normalizeSidecarSource(value.source),
};
}
export function normalizeSidecarStampCriteria(input: unknown = {}): SidecarStampCriteria {
const value = assertObject(input, "sidecar stamp criteria");
assertKnownStampKeys(value, "sidecar stamp criteria");
return {
...(value.app == null ? {} : { app: normalizeAppKey(value.app) }),
...(value.ipc == null ? {} : { ipc: normalizeIpcPath(value.ipc) }),
...(value.mode == null ? {} : { mode: normalizeSidecarMode(value.mode) }),
...(value.namespace == null ? {} : { namespace: normalizeNamespace(value.namespace) }),
...(value.source == null ? {} : { source: normalizeSidecarSource(value.source) }),
};
}
export function assertSidecarStamp(input: unknown): asserts input is SidecarStamp {
normalizeSidecarStamp(input);
}
function normalizeDesktopEvalInput(input: unknown): DesktopEvalInput {
const value = assertObject(input, "desktop eval input");
assertKnownKeys(value, ["expression"], "desktop eval input");
return { expression: normalizeNonEmptyString(value.expression, "desktop eval expression") };
}
function normalizeDesktopScreenshotInput(input: unknown): DesktopScreenshotInput {
const value = assertObject(input, "desktop screenshot input");
assertKnownKeys(value, ["path"], "desktop screenshot input");
return { path: normalizeNonEmptyString(value.path, "desktop screenshot path") };
}
function normalizeDesktopClickInput(input: unknown): DesktopClickInput {
const value = assertObject(input, "desktop click input");
assertKnownKeys(value, ["selector"], "desktop click input");
return { selector: normalizeNonEmptyString(value.selector, "desktop click selector") };
}
function normalizeMessageType(value: unknown, label: string): string {
if (typeof value !== "string" || value.length === 0) {
throw new SidecarContractError(SIDECAR_ERROR_CODES.INVALID_MESSAGE, `${label} type must be a non-empty string`);
}
return value;
}
export function normalizeDaemonSidecarMessage(input: unknown): DaemonSidecarMessage {
const value = assertObject(input, "daemon sidecar message");
const type = normalizeMessageType(value.type, "daemon sidecar message");
if (type === SIDECAR_MESSAGES.STATUS || type === SIDECAR_MESSAGES.SHUTDOWN) {
assertKnownKeys(value, ["type"], "daemon sidecar message");
return { type };
}
throw new SidecarContractError(SIDECAR_ERROR_CODES.UNKNOWN_MESSAGE, `unknown daemon sidecar message: ${type}`);
}
export function normalizeWebSidecarMessage(input: unknown): WebSidecarMessage {
const value = assertObject(input, "web sidecar message");
const type = normalizeMessageType(value.type, "web sidecar message");
if (type === SIDECAR_MESSAGES.STATUS || type === SIDECAR_MESSAGES.SHUTDOWN) {
assertKnownKeys(value, ["type"], "web sidecar message");
return { type };
}
throw new SidecarContractError(SIDECAR_ERROR_CODES.UNKNOWN_MESSAGE, `unknown web sidecar message: ${type}`);
}
export function normalizeDesktopSidecarMessage(input: unknown): DesktopSidecarMessage {
const value = assertObject(input, "desktop sidecar message");
const type = normalizeMessageType(value.type, "desktop sidecar message");
switch (type) {
case SIDECAR_MESSAGES.STATUS:
case SIDECAR_MESSAGES.SHUTDOWN:
case SIDECAR_MESSAGES.CONSOLE:
assertKnownKeys(value, ["type"], "desktop sidecar message");
return { type };
case SIDECAR_MESSAGES.EVAL:
assertKnownKeys(value, ["input", "type"], "desktop sidecar message");
return { input: normalizeDesktopEvalInput(value.input), type };
case SIDECAR_MESSAGES.SCREENSHOT:
assertKnownKeys(value, ["input", "type"], "desktop sidecar message");
return { input: normalizeDesktopScreenshotInput(value.input), type };
case SIDECAR_MESSAGES.CLICK:
assertKnownKeys(value, ["input", "type"], "desktop sidecar message");
return { input: normalizeDesktopClickInput(value.input), type };
default:
throw new SidecarContractError(SIDECAR_ERROR_CODES.UNKNOWN_MESSAGE, `unknown desktop sidecar message: ${type}`);
}
}
export const OPEN_DESIGN_SIDECAR_CONTRACT = Object.freeze({
appKeys: APP_KEYS,
defaults: SIDECAR_DEFAULTS,
env: SIDECAR_RUNTIME_ENV,
errorCodes: SIDECAR_ERROR_CODES,
messages: SIDECAR_MESSAGES,
modes: SIDECAR_MODES,
normalizeApp: normalizeAppKey,
normalizeNamespace,
normalizeSource: normalizeSidecarSource,
normalizeStamp: normalizeSidecarStamp,
normalizeStampCriteria: normalizeSidecarStampCriteria,
sources: SIDECAR_SOURCES,
stampFields: SIDECAR_STAMP_FIELDS,
stampFlags: SIDECAR_STAMP_FLAGS,
} as const satisfies OpenDesignSidecarContract);

View file

@ -0,0 +1,21 @@
{
"compilerOptions": {
"allowSyntheticDefaultImports": true,
"declaration": true,
"declarationMap": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"isolatedModules": true,
"lib": ["ES2024"],
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "./dist",
"resolveJsonModule": true,
"rootDir": "./src",
"skipLibCheck": true,
"strict": true,
"target": "ES2024",
"types": ["node"]
},
"include": ["src/**/*.ts"]
}

View file

@ -0,0 +1,11 @@
import { build } from "esbuild";
await build({
bundle: true,
entryPoints: ["./src/index.ts"],
format: "esm",
outfile: "./dist/index.mjs",
packages: "external",
platform: "node",
target: "node24",
});

View file

@ -0,0 +1,31 @@
{
"name": "@open-design/sidecar",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "./dist/index.mjs",
"types": "./dist/index.d.ts",
"files": [
"dist"
],
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.mjs"
}
},
"scripts": {
"build": "node ./esbuild.config.mjs && tsc -p tsconfig.json --emitDeclarationOnly",
"test": "vitest run",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"devDependencies": {
"@types/node": "24.12.2",
"esbuild": "0.27.7",
"vitest": "^2.1.8",
"typescript": "6.0.3"
},
"engines": {
"node": "~24"
}
}

View file

@ -0,0 +1,132 @@
import { describe, expect, it } from "vitest";
import {
bootstrapSidecarRuntime,
createSidecarLaunchEnv,
resolveAppIpcPath,
resolveAppRuntimePath,
resolveNamespace,
resolveNamespaceRoot,
resolveSidecarBase,
resolveSourceRuntimeRoot,
type SidecarContractDescriptor,
type SidecarStampShape,
} from "./index.js";
type FakeStamp = SidecarStampShape & {
app: "api" | "ui";
mode: "dev" | "prod";
source: "tool" | "pack";
};
const fakeContract: SidecarContractDescriptor<FakeStamp> = {
defaults: {
host: "127.0.0.1",
ipcBase: "/tmp/fake-product/ipc",
namespace: "default",
projectTmpDirName: ".fake-tmp",
windowsPipePrefix: "fake-product",
},
env: {
base: "FAKE_BASE",
ipcBase: "FAKE_IPC_BASE",
ipcPath: "FAKE_IPC_PATH",
namespace: "FAKE_NAMESPACE",
source: "FAKE_SOURCE",
},
normalizeApp(value) {
if (value === "api" || value === "ui") return value;
throw new Error(`unsupported fake app: ${String(value)}`);
},
normalizeNamespace(value) {
if (typeof value !== "string" || !/^[a-z0-9-]+$/.test(value)) {
throw new Error("invalid fake namespace");
}
return value;
},
normalizeSource(value) {
if (value === "tool" || value === "pack") return value;
throw new Error(`unsupported fake source: ${String(value)}`);
},
normalizeStamp(value) {
const stamp = value as Partial<FakeStamp>;
return {
app: this.normalizeApp(stamp.app),
ipc: String(stamp.ipc),
mode: stamp.mode === "prod" ? "prod" : "dev",
namespace: this.normalizeNamespace(stamp.namespace),
source: this.normalizeSource(stamp.source),
};
},
};
describe("generic sidecar path boundary", () => {
it("uses descriptor defaults instead of Open Design constants", () => {
const sourceRoot = resolveSourceRuntimeRoot({
contract: fakeContract,
projectRoot: "/repo/product",
source: "tool",
});
expect(sourceRoot).toBe("/repo/product/.fake-tmp/tool");
expect(resolveNamespaceRoot({ base: sourceRoot, contract: fakeContract, namespace: "alpha" })).toBe(
"/repo/product/.fake-tmp/tool/alpha",
);
expect(
resolveAppRuntimePath({
app: "ui",
contract: fakeContract,
fileName: "cache",
namespaceRoot: "/repo/product/.fake-tmp/tool/alpha",
}),
).toBe("/repo/product/.fake-tmp/tool/alpha/ui/cache");
});
it("resolves descriptor-specific IPC paths", () => {
expect(resolveAppIpcPath({ app: "ui", contract: fakeContract, namespace: "alpha" })).toBe(
process.platform === "win32" ? "\\\\.\\pipe\\fake-product-alpha-ui" : "/tmp/fake-product/ipc/alpha/ui.sock",
);
});
it("resolves namespace and base from descriptor env names", () => {
const env = {
FAKE_BASE: "/runtime/base",
FAKE_NAMESPACE: "selected",
};
expect(resolveNamespace({ contract: fakeContract, env })).toBe("selected");
expect(resolveSidecarBase({ contract: fakeContract, env, projectRoot: "/repo/product", source: "tool" })).toBe(
"/runtime/base",
);
});
});
describe("generic sidecar bootstrap", () => {
it("creates and validates launch env from descriptor env names", () => {
const stamp: FakeStamp = {
app: "api",
ipc: "/tmp/fake-product/ipc/alpha/api.sock",
mode: "dev",
namespace: "alpha",
source: "tool",
};
expect(createSidecarLaunchEnv({ base: "/runtime/base", contract: fakeContract, extraEnv: {}, stamp })).toEqual({
FAKE_BASE: "/runtime/base",
FAKE_IPC_PATH: stamp.ipc,
FAKE_NAMESPACE: stamp.namespace,
FAKE_SOURCE: stamp.source,
});
expect(
bootstrapSidecarRuntime(stamp, { FAKE_BASE: "/runtime/base" }, { app: "api", contract: fakeContract }),
).toEqual({
app: "api",
base: "/runtime/base",
ipc: stamp.ipc,
mode: "dev",
namespace: "alpha",
source: "tool",
});
});
});

View file

@ -0,0 +1,565 @@
import { lstat, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
import { createConnection, createServer as createNetServer, type Server } from "node:net";
import { dirname, isAbsolute, join, resolve } from "node:path";
export type SidecarStampShape = {
app: string;
ipc: string;
mode: string;
namespace: string;
source: string;
};
export type SidecarContractDescriptor<TStamp extends SidecarStampShape = SidecarStampShape> = {
defaults: {
host: string;
ipcBase: string;
namespace: string;
projectTmpDirName: string;
windowsPipePrefix: string;
};
env: {
base: string;
ipcBase: string;
ipcPath: string;
namespace: string;
source: string;
};
normalizeApp(app: unknown): TStamp["app"];
normalizeNamespace(namespace: unknown): string;
normalizeSource(source: unknown): TStamp["source"];
normalizeStamp(input: unknown): TStamp;
};
export type NamespaceResolutionOptions<TStamp extends SidecarStampShape = SidecarStampShape> = {
contract: SidecarContractDescriptor<TStamp>;
env?: NodeJS.ProcessEnv;
namespace?: string | null;
};
export type ProjectRuntimePathRequest<TStamp extends SidecarStampShape = SidecarStampShape> = {
contract: SidecarContractDescriptor<TStamp>;
projectRoot: string;
source: TStamp["source"] | string;
};
export type BaseResolutionOptions<TStamp extends SidecarStampShape = SidecarStampShape> = {
base?: string | null;
contract: SidecarContractDescriptor<TStamp>;
env?: NodeJS.ProcessEnv;
projectRoot?: string;
source: TStamp["source"] | string;
};
export type RuntimePathRequest<TStamp extends SidecarStampShape = SidecarStampShape> = {
base: string;
contract: SidecarContractDescriptor<TStamp>;
namespace: string;
};
export type RuntimeRootRequest<TStamp extends SidecarStampShape = SidecarStampShape> = RuntimePathRequest<TStamp> & {
runId: string;
};
export type AppIpcPathRequest<TStamp extends SidecarStampShape = SidecarStampShape> = {
app: TStamp["app"] | string;
contract: SidecarContractDescriptor<TStamp>;
env?: NodeJS.ProcessEnv;
namespace: string;
};
export type AppRuntimePathRequest<TStamp extends SidecarStampShape = SidecarStampShape> = {
app: TStamp["app"] | string;
contract: SidecarContractDescriptor<TStamp>;
namespaceRoot: string;
};
export type SidecarRuntimeContext<TStamp extends SidecarStampShape = SidecarStampShape> = {
app: TStamp["app"];
base: string;
ipc: string;
mode: TStamp["mode"];
namespace: string;
source: TStamp["source"];
};
export type SidecarLaunchEnvRequest<TStamp extends SidecarStampShape = SidecarStampShape> = {
base: string;
contract: SidecarContractDescriptor<TStamp>;
extraEnv?: NodeJS.ProcessEnv;
stamp: TStamp;
};
export type BootstrapSidecarRuntimeOptions<TStamp extends SidecarStampShape = SidecarStampShape> = {
app: TStamp["app"] | string;
base?: string | null;
contract: SidecarContractDescriptor<TStamp>;
projectRoot?: string;
};
export type PortAllocation = {
port: number;
source: "dynamic" | "forced";
};
export type PortRequest = {
host?: string;
label?: string;
port?: number | string | null;
reserved?: Set<number>;
};
export type JsonIpcHandler = (message: any) => unknown | Promise<unknown>;
export type JsonIpcServerHandle = {
close(): Promise<void>;
};
export function isWindowsNamedPipePath(value: unknown): boolean {
return typeof value === "string" && value.startsWith("\\\\.\\pipe\\");
}
export function normalizeIpcPath(ipc: unknown): string {
if (typeof ipc !== "string") throw new Error("sidecar ipc path must be a string");
if (ipc.length === 0) throw new Error("sidecar ipc path must not be empty");
if (ipc.trim() !== ipc) throw new Error("sidecar ipc path must not contain leading or trailing whitespace");
if (ipc.includes("\0")) throw new Error("sidecar ipc path must not contain null bytes");
if (isWindowsNamedPipePath(ipc)) return ipc;
if (!isAbsolute(ipc)) throw new Error(`sidecar ipc path must be absolute: ${ipc}`);
return ipc;
}
export function resolveNamespace<TStamp extends SidecarStampShape>(options: NamespaceResolutionOptions<TStamp>): string {
return options.contract.normalizeNamespace(
options.namespace ??
options.env?.[options.contract.env.namespace] ??
options.contract.defaults.namespace,
);
}
export function resolveProjectRoot(projectRoot: string): string {
if (typeof projectRoot !== "string" || projectRoot.trim().length === 0) {
throw new Error("projectRoot must be a non-empty string");
}
return resolve(projectRoot);
}
export function resolveProjectTmpRoot<TStamp extends SidecarStampShape>({
contract,
projectRoot,
}: {
contract: SidecarContractDescriptor<TStamp>;
projectRoot: string;
}): string {
return join(resolveProjectRoot(projectRoot), contract.defaults.projectTmpDirName);
}
export function resolveSourceRuntimeRoot<TStamp extends SidecarStampShape>({
contract,
projectRoot,
source,
}: ProjectRuntimePathRequest<TStamp>): string {
return join(resolveProjectTmpRoot({ contract, projectRoot }), contract.normalizeSource(source));
}
export function resolveSidecarBase<TStamp extends SidecarStampShape>({
base,
contract,
env = process.env,
projectRoot = process.cwd(),
source,
}: BaseResolutionOptions<TStamp>): string {
return resolve(base ?? env[contract.env.base] ?? resolveSourceRuntimeRoot({ contract, projectRoot, source }));
}
export function resolveNamespaceRoot<TStamp extends SidecarStampShape>({
base,
contract,
namespace,
}: RuntimePathRequest<TStamp>): string {
return join(resolve(base), contract.normalizeNamespace(namespace));
}
export function resolveRuntimeRoot<TStamp extends SidecarStampShape>({
base,
contract,
namespace,
runId,
}: RuntimeRootRequest<TStamp>): string {
return join(resolveNamespaceRoot({ base, contract, namespace }), "runs", runId);
}
export function resolvePointerPath<TStamp extends SidecarStampShape>({ base, contract, namespace }: RuntimePathRequest<TStamp>): string {
return join(resolveNamespaceRoot({ base, contract, namespace }), "current.json");
}
export function resolveManifestPath({ runtimeRoot }: { runtimeRoot: string }): string {
return join(runtimeRoot, "manifest.json");
}
export function resolveLogsDir<TStamp extends SidecarStampShape>({
app,
contract,
runtimeRoot,
}: {
app: TStamp["app"] | string;
contract: SidecarContractDescriptor<TStamp>;
runtimeRoot: string;
}): string {
return join(runtimeRoot, "logs", contract.normalizeApp(app));
}
export function resolveLogFilePath<TStamp extends SidecarStampShape>({
app,
contract,
fileName = "latest.log",
runtimeRoot,
}: {
app: TStamp["app"] | string;
contract: SidecarContractDescriptor<TStamp>;
fileName?: string;
runtimeRoot: string;
}): string {
return join(resolveLogsDir({ app, contract, runtimeRoot }), fileName);
}
export function resolveAppRuntimeDir<TStamp extends SidecarStampShape>({
app,
contract,
namespaceRoot,
}: AppRuntimePathRequest<TStamp>): string {
return join(namespaceRoot, contract.normalizeApp(app));
}
export function resolveAppRuntimePath<TStamp extends SidecarStampShape>({
app,
contract,
fileName,
namespaceRoot,
}: AppRuntimePathRequest<TStamp> & { fileName: string }): string {
if (fileName.length === 0 || fileName.includes("\0") || /[\\/]/.test(fileName)) {
throw new Error(`app runtime fileName must be a simple path segment: ${fileName}`);
}
return join(resolveAppRuntimeDir({ app, contract, namespaceRoot }), fileName);
}
export function resolveAppIpcPath<TStamp extends SidecarStampShape>({
app,
contract,
env = process.env,
namespace,
}: AppIpcPathRequest<TStamp>): string {
const normalizedApp = contract.normalizeApp(app);
const normalizedNamespace = contract.normalizeNamespace(namespace);
if (process.platform === "win32") {
return `\\\\.\\pipe\\${contract.defaults.windowsPipePrefix}-${normalizedNamespace}-${normalizedApp}`;
}
const ipcBase = resolve(env[contract.env.ipcBase] ?? contract.defaults.ipcBase);
return join(ipcBase, normalizedNamespace, `${normalizedApp}.sock`);
}
export function createSidecarLaunchEnv<TStamp extends SidecarStampShape>({
base,
contract,
extraEnv = process.env,
stamp,
}: SidecarLaunchEnvRequest<TStamp>): NodeJS.ProcessEnv {
const normalizedStamp = contract.normalizeStamp(stamp);
return {
...extraEnv,
[contract.env.base]: resolveSidecarBase({ base, contract, env: extraEnv, source: normalizedStamp.source }),
[contract.env.ipcPath]: normalizedStamp.ipc,
[contract.env.namespace]: normalizedStamp.namespace,
[contract.env.source]: normalizedStamp.source,
};
}
function assertMatchingEnv(env: NodeJS.ProcessEnv, key: string, expected: string): void {
const current = env[key];
if (current != null && current !== expected) {
throw new Error(`sidecar env mismatch for ${key}: expected ${expected}, received ${current}`);
}
}
export function bootstrapSidecarRuntime<TStamp extends SidecarStampShape>(
stampInput: unknown,
env: NodeJS.ProcessEnv,
options: BootstrapSidecarRuntimeOptions<TStamp>,
): SidecarRuntimeContext<TStamp> {
const stamp = options.contract.normalizeStamp(stampInput);
const expectedApp = options.contract.normalizeApp(options.app);
if (stamp.app !== expectedApp) {
throw new Error(`sidecar stamp app mismatch: expected ${expectedApp}, received ${stamp.app}`);
}
const base = resolveSidecarBase({
base: options.base,
contract: options.contract,
env,
projectRoot: options.projectRoot,
source: stamp.source,
});
const ipc = resolveAppIpcPath({ app: stamp.app, contract: options.contract, env, namespace: stamp.namespace });
if (stamp.ipc !== ipc) {
throw new Error(`sidecar ipc path mismatch: expected ${ipc}, received ${stamp.ipc}`);
}
assertMatchingEnv(env, options.contract.env.ipcPath, stamp.ipc);
assertMatchingEnv(env, options.contract.env.namespace, stamp.namespace);
assertMatchingEnv(env, options.contract.env.source, stamp.source);
env[options.contract.env.ipcPath] = ipc;
env[options.contract.env.namespace] = stamp.namespace;
env[options.contract.env.source] = stamp.source;
return {
app: stamp.app,
base,
ipc,
mode: stamp.mode,
namespace: stamp.namespace,
source: stamp.source,
};
}
async function closeServer(server: Server): Promise<void> {
if (!server.listening) return;
await new Promise<void>((resolveClose, rejectClose) => {
server.close((error) => (error == null ? resolveClose() : rejectClose(error)));
});
}
async function listenOnPort(port: number, host: string): Promise<Server> {
const server = createNetServer();
await new Promise<void>((resolveListen, rejectListen) => {
server.once("error", rejectListen);
server.listen({ port, host, exclusive: true }, () => {
server.off("error", rejectListen);
resolveListen();
});
});
return server;
}
function parsePort(value: number | string | null | undefined, label: string): number | null {
if (value == null || value === "") return null;
const port = Number(value);
if (!Number.isInteger(port) || port <= 0 || port > 65535) {
throw new Error(`${label} port must be an integer between 1 and 65535`);
}
return port;
}
function errorCode(error: unknown): string | null {
if (typeof error !== "object" || error == null || !("code" in error)) return null;
const code = (error as { code?: unknown }).code;
return code == null ? null : String(code);
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function jsonIpcError(error: unknown): { code?: string; message: string } {
return {
...(errorCode(error) == null ? {} : { code: errorCode(error) as string }),
message: errorMessage(error),
};
}
async function allocateForcedPort(port: number, label: string, host: string, reserved: Set<number>): Promise<PortAllocation> {
if (reserved.has(port)) {
throw new Error(`forced ${label} port ${port} conflicts with another managed port`);
}
let server: Server | null = null;
try {
server = await listenOnPort(port, host);
} catch (error) {
throw new Error(`forced ${label} port ${port} is not available (${errorCode(error) ?? errorMessage(error)})`);
} finally {
if (server) await closeServer(server);
}
reserved.add(port);
return { port, source: "forced" };
}
async function allocateDynamicPort(label: string, host: string, reserved: Set<number>): Promise<PortAllocation> {
for (let attempt = 0; attempt < 20; attempt += 1) {
const server = await listenOnPort(0, host);
const address = server.address();
await closeServer(server);
if (address == null || typeof address === "string") {
throw new Error(`failed to allocate dynamic ${label} port`);
}
if (!reserved.has(address.port)) {
reserved.add(address.port);
return { port: address.port, source: "dynamic" };
}
}
throw new Error(`failed to allocate dynamic ${label} port without conflict`);
}
export async function allocatePort({
host = "127.0.0.1",
label = "runtime",
port,
reserved = new Set<number>(),
}: PortRequest = {}): Promise<PortAllocation> {
const forcedPort = parsePort(port, label);
return forcedPort == null
? await allocateDynamicPort(label, host, reserved)
: await allocateForcedPort(forcedPort, label, host, reserved);
}
export async function readJsonFile<T = any>(filePath: string): Promise<T | null> {
try {
return JSON.parse(await readFile(filePath, "utf8")) as T;
} catch {
return null;
}
}
export async function writeJsonFile(filePath: string, payload: unknown): Promise<void> {
await mkdir(dirname(filePath), { recursive: true });
const tmpPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
await writeFile(tmpPath, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
await rename(tmpPath, filePath);
}
export async function removeFile(filePath: string): Promise<void> {
await rm(filePath, { force: true });
}
export async function removePointerIfCurrent(pointerPath: string, runId: string): Promise<void> {
const pointer = await readJsonFile<{ runId?: string }>(pointerPath);
if (pointer?.runId === runId) await removeFile(pointerPath);
}
async function staleUnixSocketExists(socketPath: string): Promise<boolean> {
try {
const stat = await lstat(socketPath);
if (!stat.isSocket()) return false;
} catch (error) {
if (errorCode(error) === "ENOENT") return false;
throw error;
}
return await new Promise<boolean>((resolveStale, rejectStale) => {
const socket = createConnection(socketPath);
let settled = false;
const settle = (callback: () => void) => {
if (settled) return;
settled = true;
socket.removeAllListeners();
socket.destroy();
callback();
};
socket.once("connect", () => settle(() => resolveStale(false)));
socket.once("error", (error) => {
const code = errorCode(error);
if (code === "ENOENT" || code === "ECONNREFUSED") {
settle(() => resolveStale(true));
return;
}
settle(() => rejectStale(error));
});
});
}
async function prepareIpcPath(socketPath: string): Promise<void> {
if (isWindowsNamedPipePath(socketPath)) return;
await mkdir(dirname(socketPath), { recursive: true });
if (await staleUnixSocketExists(socketPath)) await rm(socketPath, { force: true });
}
export async function createJsonIpcServer({
handler,
socketPath,
}: {
handler: JsonIpcHandler;
socketPath: string;
}): Promise<JsonIpcServerHandle> {
await prepareIpcPath(socketPath);
const server = createNetServer((socket) => {
let buffer = "";
socket.on("data", async (chunk) => {
buffer += chunk.toString();
const newlineIndex = buffer.indexOf("\n");
if (newlineIndex < 0) return;
const frame = buffer.slice(0, newlineIndex);
buffer = buffer.slice(newlineIndex + 1);
try {
const result = await handler(JSON.parse(frame));
socket.end(`${JSON.stringify({ ok: true, result })}\n`);
} catch (error) {
socket.end(
`${JSON.stringify({
ok: false,
error: jsonIpcError(error),
})}\n`,
);
}
});
});
await new Promise<void>((resolveListen, rejectListen) => {
server.once("error", rejectListen);
server.listen(socketPath, () => {
server.off("error", rejectListen);
resolveListen();
});
});
return {
async close() {
await closeServer(server);
if (!isWindowsNamedPipePath(socketPath)) await rm(socketPath, { force: true });
},
};
}
export async function requestJsonIpc<T = any>(
socketPath: string,
payload: unknown,
{ timeoutMs = 1500 }: { timeoutMs?: number } = {},
): Promise<T> {
return await new Promise<T>((resolveRequest, rejectRequest) => {
const socket = createConnection(socketPath);
let settled = false;
let buffer = "";
const settle = (callback: () => void) => {
if (settled) return;
settled = true;
clearTimeout(timeout);
callback();
};
const timeout = setTimeout(() => {
socket.destroy();
settle(() => rejectRequest(new Error(`IPC request timed out: ${socketPath}`)));
}, timeoutMs);
socket.on("connect", () => {
socket.write(`${JSON.stringify(payload)}\n`);
});
socket.on("data", (chunk) => {
buffer += chunk.toString();
const newlineIndex = buffer.indexOf("\n");
if (newlineIndex < 0) return;
socket.end();
settle(() => {
const response = JSON.parse(buffer.slice(0, newlineIndex)) as { error?: { message?: string }; ok: boolean; result?: T };
if (!response.ok) {
rejectRequest(new Error(response.error?.message ?? "IPC request failed"));
return;
}
resolveRequest(response.result as T);
});
});
socket.on("error", (error) => {
settle(() => rejectRequest(error));
});
});
}

View file

@ -0,0 +1,21 @@
{
"compilerOptions": {
"allowSyntheticDefaultImports": true,
"declaration": true,
"declarationMap": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"isolatedModules": true,
"lib": ["ES2024"],
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "./dist",
"resolveJsonModule": true,
"rootDir": "./src",
"skipLibCheck": true,
"strict": true,
"target": "ES2024",
"types": ["node"]
},
"include": ["src/**/*.ts"]
}

File diff suppressed because it is too large Load diff

View file

@ -1,4 +1,5 @@
packages:
- packages/*
- apps/*
- tools/*
- e2e

View file

@ -15,8 +15,20 @@ const skippedDirectories = new Set([
".od-e2e",
".opencode",
".task",
".tmp",
".vite",
"dist",
"node_modules",
"out",
]);
const allowedExactPaths = new Set([
"packages/platform/esbuild.config.mjs",
"packages/sidecar/esbuild.config.mjs",
"packages/sidecar-proto/esbuild.config.mjs",
"scripts/postinstall.mjs",
"tools/dev/bin/tools-dev.mjs",
"tools/dev/esbuild.config.mjs",
]);
const allowedPathPrefixes = [
@ -37,9 +49,14 @@ function toRepositoryPath(filePath: string): string {
}
function isAllowedOutputPath(repositoryPath: string): boolean {
if (allowedExactPaths.has(repositoryPath)) return true;
return allowedPathPrefixes.some((prefix) => repositoryPath.startsWith(prefix));
}
function isSkippedDirectoryName(directoryName: string): boolean {
return skippedDirectories.has(directoryName) || directoryName === ".next" || directoryName.startsWith(".next-");
}
async function collectResidualJavaScript(directory: string): Promise<string[]> {
const entries = await readdir(directory, { withFileTypes: true });
const residualFiles: string[] = [];
@ -49,7 +66,7 @@ async function collectResidualJavaScript(directory: string): Promise<string[]> {
const repositoryPath = toRepositoryPath(fullPath);
if (entry.isDirectory()) {
if (skippedDirectories.has(entry.name) || isAllowedOutputPath(`${repositoryPath}/`)) {
if (isSkippedDirectoryName(entry.name) || isAllowedOutputPath(`${repositoryPath}/`)) {
continue;
}

View file

@ -1,88 +0,0 @@
#!/usr/bin/env node
// Launcher for `pnpm run dev:all`.
//
// Probes for free ports for the daemon (OD_PORT, default 7456) and the
// Next.js dev server (NEXT_PORT, default 3000) before spawning the workspace
// apps, so a stray process holding either port doesn't kill the
// whole boot. The resolved ports are exported into the child env, which
// means:
// * the daemon's cli.ts sees the new OD_PORT and binds to it
// * apps/web/next.config.ts reads the same OD_PORT and proxies /api, /artifacts,
// /frames to the daemon's actual port
// * Next.js binds to NEXT_PORT (we pass `-p $NEXT_PORT` to the web package
// dev script so it can stay parameter-free for the common single-process
// case where the user runs just `pnpm dev`)
//
// If a port is busy we walk forward up to PORT_SEARCH_RANGE steps and log
// the switch so the user notices.
import { spawn } from 'node:child_process';
import { findFreePort } from './resolve-dev-ports.ts';
const desiredDaemon = Number(process.env.OD_PORT) || 7456;
const desiredNext = Number(process.env.NEXT_PORT) || 3000;
const strictDaemonPort = process.env.OD_PORT_STRICT === '1';
const strictNextPort = process.env.NEXT_PORT_STRICT === '1';
const daemonPort = strictDaemonPort
? desiredDaemon
: await findFreePort(desiredDaemon, 'daemon');
const nextPort = strictNextPort
? desiredNext
: await findFreePort(desiredNext, 'next');
if (daemonPort !== desiredDaemon) {
console.log(
`[dev:all] daemon port ${desiredDaemon} is busy, switching to ${daemonPort}`,
);
}
if (nextPort !== desiredNext) {
console.log(
`[dev:all] next port ${desiredNext} is busy, switching to ${nextPort}`,
);
}
const env = {
...process.env,
OD_PORT: String(daemonPort),
NEXT_PORT: String(nextPort),
PORT: String(nextPort),
};
const packageManager = process.platform === 'win32' ? 'corepack.cmd' : 'corepack';
const children = [
spawn(packageManager, ['pnpm', '--filter', '@open-design/daemon', 'daemon'], {
env,
stdio: 'inherit',
}),
spawn(packageManager, ['pnpm', '--filter', '@open-design/web', 'dev', '-p', String(nextPort)], {
env,
stdio: 'inherit',
}),
];
let shuttingDown = false;
function stopChildren(signal: NodeJS.Signals = 'SIGTERM'): void {
for (const child of children) {
if (!child.killed) child.kill(signal);
}
}
for (const child of children) {
child.on('exit', (code, signal) => {
if (shuttingDown) return;
shuttingDown = true;
stopChildren(signal || 'SIGTERM');
if (signal) process.kill(process.pid, signal);
else process.exit(code ?? 0);
});
}
for (const sig of ['SIGINT', 'SIGTERM'] as const) {
process.on(sig, () => {
shuttingDown = true;
stopChildren(sig);
});
}

43
scripts/postinstall.mjs Normal file
View file

@ -0,0 +1,43 @@
import { spawnSync } from "node:child_process";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const scriptDir = dirname(fileURLToPath(import.meta.url));
const repoRoot = resolve(scriptDir, "..");
const buildTargets = [
"packages/sidecar-proto",
"packages/sidecar",
"packages/platform",
"tools/dev",
];
function resolvePackageManagerInvocation() {
const pnpmExecPath = process.env.npm_execpath;
if (pnpmExecPath != null && pnpmExecPath.length > 0) {
return { argsPrefix: [pnpmExecPath], command: process.execPath };
}
return { argsPrefix: [], command: process.platform === "win32" ? "pnpm.cmd" : "pnpm" };
}
const packageManager = resolvePackageManagerInvocation();
for (const target of buildTargets) {
const result = spawnSync(
packageManager.command,
[...packageManager.argsPrefix, "-C", target, "run", "build"],
{
cwd: repoRoot,
stdio: "inherit",
},
);
if (result.error != null) {
throw result.error;
}
if (result.status !== 0) {
process.exit(result.status ?? 1);
}
}

View file

@ -1,63 +0,0 @@
import net from 'node:net';
const HOST = '127.0.0.1';
const DEFAULT_PORT_SEARCH_RANGE = 50;
export interface PortSearchOptions {
host?: string;
searchRange?: number;
}
export interface ResolveDevPortsOptions extends PortSearchOptions {
daemonStart?: number;
appStart?: number;
appLabel?: string;
}
export interface ResolvedDevPorts {
daemonPort: number;
appPort: number;
}
export function isPortFree(port: number, host = HOST): Promise<boolean> {
return new Promise((resolve) => {
const server = net.createServer();
server.unref();
server.once('error', () => resolve(false));
server.listen({ port, host, exclusive: true }, () => {
server.close(() => resolve(true));
});
});
}
export async function findFreePort(
start: number,
label: string,
{ host = HOST, searchRange = DEFAULT_PORT_SEARCH_RANGE }: PortSearchOptions = {},
): Promise<number> {
for (let port = start; port < start + searchRange; port += 1) {
if (await isPortFree(port, host)) return port;
}
throw new Error(
`[dev:all] could not find a free ${label} port near ${start} (tried ${searchRange})`,
);
}
export async function resolveDevPorts({
daemonStart = 7456,
appStart = 5173,
appLabel = 'app',
host = HOST,
searchRange = DEFAULT_PORT_SEARCH_RANGE,
}: ResolveDevPortsOptions = {}): Promise<ResolvedDevPorts> {
const daemonPort = await findFreePort(daemonStart, 'daemon', {
host,
searchRange,
});
const appPort = await findFreePort(appStart, appLabel, {
host,
searchRange,
});
return { daemonPort, appPort };
}

View file

@ -50,17 +50,17 @@ created: '2026-04-30'
- Dev-mode web rewrites `/api/*`, `/artifacts/*`, and `/frames/*` to the local daemon origin; the config notes that `/api/chat` SSE streams through the rewrite. Source: `apps/web/next.config.ts:35-44`
- Web-side daemon chat types live in `apps/web/src/providers/daemon.ts`: `DaemonStreamOptions` sends `agentId`, `history`, `systemPrompt`, `projectId`, `attachments`, `model`, and `reasoning`. Source: `apps/web/src/providers/daemon.ts:19-38`
- The web chat client posts `/api/chat` with JSON fields `agentId`, `systemPrompt`, `message`, `projectId`, `attachments`, `model`, and `reasoning`. Source: `apps/web/src/providers/daemon.ts:57-77`
- The daemon `/api/chat` handler reads the same request fields from `req.body`, validates agent and message ad hoc, and returns HTTP 400 JSON errors for invalid agent, missing binary, or missing message. Source: `apps/daemon/server.js:868-884`
- The daemon `/api/chat` handler reads the same request fields from `req.body`, validates agent and message ad hoc, and returns HTTP 400 JSON errors for invalid agent, missing binary, or missing message. Source: `apps/daemon/src/server.ts:868-884`
- Web-side `AgentEvent` currently models UI events as `status`, `text`, `thinking`, `tool_use`, `tool_result`, `usage`, and `raw`. Source: `apps/web/src/types.ts:32-39`
- Daemon SSE setup for `/api/chat` writes `text/event-stream` frames with `event: <name>` and JSON `data`, using events such as `start`, `agent`, `stdout`, `stderr`, `error`, and `end`. Source: `apps/daemon/server.js:1035-1044`, `apps/daemon/server.js:1087-1095`, `apps/daemon/server.js:1136-1180`
- Daemon SSE setup for `/api/chat` writes `text/event-stream` frames with `event: <name>` and JSON `data`, using events such as `start`, `agent`, `stdout`, `stderr`, `error`, and `end`. Source: `apps/daemon/src/server.ts:1035-1044`, `apps/daemon/src/server.ts:1087-1095`, `apps/daemon/src/server.ts:1136-1180`
- The web SSE parser consumes frame separators, parses event/data fields, maps `stdout` to text, buffers `stderr`, translates `agent` payloads, handles `start`, treats `error` as terminal, and reads `end` exit code. Source: `apps/web/src/providers/daemon.ts:85-151`
- Web translation accepts daemon `agent` payload types `status`, `text_delta`, `thinking_delta`, `thinking_start`, `tool_use`, `tool_result`, `usage`, and `raw`; unknown payloads are ignored. Source: `apps/web/src/providers/daemon.ts:178-228`
- Agent JSON event parsing emits normalized events such as `status`, `text_delta`, `tool_use`, `tool_result`, `usage`, and `raw`; OpenCode error payloads currently become a `raw` event with embedded error text. Source: `apps/daemon/json-event-stream.js:35-91`
- The daemon API proxy has a separate SSE endpoint at `/api/proxy/stream` with request fields `baseUrl`, `apiKey`, `model`, `systemPrompt`, and `messages`, and returns `start`, `delta`, `error`, and `end` SSE events. Source: `apps/daemon/server.js:1188-1192`, `apps/daemon/server.js:1241-1250`, `apps/daemon/server.js:1262-1275`, `apps/daemon/server.js:1291-1303`
- HTTP error responses are ad hoc: project routes often return `{ error: string }`, upload errors return `{ code, error }`, and preview errors derive status plus `{ error }`. Source: `apps/daemon/server.js:200-205`, `apps/daemon/server.js:147-177`, `apps/daemon/server.js:755-763`
- Project CRUD and conversation/message routes shape common response envelopes such as `{ projects }`, `{ project, conversationId }`, `{ project }`, `{ conversations }`, `{ conversation }`, and `{ messages }`. Source: `apps/daemon/server.js:200-269`, `apps/daemon/server.js:325-424`
- File routes shape common response envelopes such as `{ files }`, `{ file }`, and `{ ok: true }`, while raw file routes return binary data. Source: `apps/daemon/server.js:725-752`, `apps/daemon/server.js:776-833`, `apps/daemon/server.js:840-864`
- `apps/daemon/projects.js` owns project file DTO construction with fields `name`, `path`, `type`, `size`, `mtime`, `kind`, `mime`, `artifactKind`, and `artifactManifest`. Source: `apps/daemon/projects.js:30-70`
- Agent JSON event parsing emits normalized events such as `status`, `text_delta`, `tool_use`, `tool_result`, `usage`, and `raw`; OpenCode error payloads currently become a `raw` event with embedded error text. Source: `apps/daemon/src/json-event-stream.ts:35-91`
- The daemon API proxy has a separate SSE endpoint at `/api/proxy/stream` with request fields `baseUrl`, `apiKey`, `model`, `systemPrompt`, and `messages`, and returns `start`, `delta`, `error`, and `end` SSE events. Source: `apps/daemon/src/server.ts:1188-1192`, `apps/daemon/src/server.ts:1241-1250`, `apps/daemon/src/server.ts:1262-1275`, `apps/daemon/src/server.ts:1291-1303`
- HTTP error responses are ad hoc: project routes often return `{ error: string }`, upload errors return `{ code, error }`, and preview errors derive status plus `{ error }`. Source: `apps/daemon/src/server.ts:200-205`, `apps/daemon/src/server.ts:147-177`, `apps/daemon/src/server.ts:755-763`
- Project CRUD and conversation/message routes shape common response envelopes such as `{ projects }`, `{ project, conversationId }`, `{ project }`, `{ conversations }`, `{ conversation }`, and `{ messages }`. Source: `apps/daemon/src/server.ts:200-269`, `apps/daemon/src/server.ts:325-424`
- File routes shape common response envelopes such as `{ files }`, `{ file }`, and `{ ok: true }`, while raw file routes return binary data. Source: `apps/daemon/src/server.ts:725-752`, `apps/daemon/src/server.ts:776-833`, `apps/daemon/src/server.ts:840-864`
- `apps/daemon/src/projects.ts` owns project file DTO construction with fields `name`, `path`, `type`, `size`, `mtime`, `kind`, `mime`, `artifactKind`, and `artifactManifest`. Source: `apps/daemon/src/projects.ts:30-70`
- Web application types already include daemon-adjacent DTOs such as `AgentInfo`, `ProjectFileKind`, `ProjectFile`, `Project`, and chat attachment/message/event types in `apps/web/src/types.ts`. Source: `apps/web/src/types.ts:41-101`, `apps/web/src/types.ts:150-160`
- `apps/web` has TypeScript configured with `strict`, `noUncheckedIndexedAccess`, `allowJs`, `noEmit`, and a `typecheck` script using `tsc -b --noEmit`. Source: `apps/web/tsconfig.json:2-23`, `apps/web/package.json:6-10`
- `apps/daemon` is ESM, starts with `node cli.js`, tests with `vitest run -c vitest.config.ts`, and currently has no `typecheck` script. Source: `apps/daemon/package.json:1-23`
@ -84,9 +84,9 @@ created: '2026-04-30'
- Runtime validation for HTTP inputs, paths, agents, models, uploads, task IDs, and command args is W4 scope, so research for W2/W3 should capture type boundaries without implementing full validation policy yet. Source: `specs/current/maintainability-roadmap.md:59`
- Shared code must stay free of Next.js, Express, Node filesystem/process APIs, browser APIs, SQLite, and daemon internals. Source: `specs/current/architecture-boundaries.md:41-56`
- API DTOs should prefer workspace-scoped logical or relative paths; machine absolute paths should remain daemon-internal. Source: `specs/current/architecture-boundaries.md:58-64`
- The `/api/chat` stream currently includes daemon-internal `cwd` in the `start` SSE event. Source: `apps/daemon/server.js:1087-1095`
- Current daemon SSE lifecycle has no heartbeat or version field in emitted events. Source: `apps/daemon/server.js:1035-1044`, `apps/daemon/server.js:1087-1180`
- Current error responses and SSE errors do not use a unified model with `code`, `message`, `details`, `retryable`, and `requestId/taskId`. Source: `apps/daemon/server.js:147-177`, `apps/daemon/server.js:200-205`, `apps/daemon/server.js:868-884`, `apps/daemon/server.js:1170-1180`
- The `/api/chat` stream currently includes daemon-internal `cwd` in the `start` SSE event. Source: `apps/daemon/src/server.ts:1087-1095`
- Current daemon SSE lifecycle has no heartbeat or version field in emitted events. Source: `apps/daemon/src/server.ts:1035-1044`, `apps/daemon/src/server.ts:1087-1180`
- Current error responses and SSE errors do not use a unified model with `code`, `message`, `details`, `retryable`, and `requestId/taskId`. Source: `apps/daemon/src/server.ts:147-177`, `apps/daemon/src/server.ts:200-205`, `apps/daemon/src/server.ts:868-884`, `apps/daemon/src/server.ts:1170-1180`
- Daemon package devDependencies currently include `vitest` only; TypeScript and Node/Express type packages are available in web but not daemon. Source: `apps/daemon/package.json:21-23`, `apps/web/package.json:19-24`
- Full-project TypeScript migration includes CommonJS/MJS operational edges such as Playwright reporter loading and Node test/script execution. Source: `e2e/playwright.config.ts:22-37`, `e2e/package.json:8-12`, `package.json:14-15`
@ -99,11 +99,11 @@ created: '2026-04-30'
- `apps/web/src/providers/daemon.ts:85-151` - web-side SSE frame handling.
- `apps/web/src/providers/daemon.ts:178-228` - web-side daemon agent event translation.
- `apps/web/src/types.ts:32-39` - current UI `AgentEvent` union.
- `apps/daemon/server.js:868-884` - daemon `/api/chat` request field handling and ad hoc HTTP errors.
- `apps/daemon/server.js:1035-1044` - daemon SSE frame writer.
- `apps/daemon/server.js:1087-1180` - daemon `/api/chat` start/agent/stdout/stderr/error/end lifecycle.
- `apps/daemon/server.js:1188-1303` - daemon API proxy stream request and SSE events.
- `apps/daemon/json-event-stream.js:35-91` - normalized agent JSON event output.
- `apps/daemon/src/server.ts:868-884` - daemon `/api/chat` request field handling and ad hoc HTTP errors.
- `apps/daemon/src/server.ts:1035-1044` - daemon SSE frame writer.
- `apps/daemon/src/server.ts:1087-1180` - daemon `/api/chat` start/agent/stdout/stderr/error/end lifecycle.
- `apps/daemon/src/server.ts:1188-1303` - daemon API proxy stream request and SSE events.
- `apps/daemon/src/json-event-stream.ts:35-91` - normalized agent JSON event output.
- `apps/daemon/package.json:9-23` - daemon scripts and dependencies.
- `apps/web/tsconfig.json:2-23` - web TypeScript baseline.
- `e2e/package.json:6-12` - e2e test scripts that currently execute TS config plus MJS runtime support.
@ -130,14 +130,14 @@ flowchart LR
- Decision: Add a new `packages/contracts` workspace package for W2. The package exports pure TypeScript types for daemon HTTP DTOs, SSE event unions, task states, and error codes; this aligns with the shared-boundary allowed contents and the roadmap's shared-contract output. Source: `specs/current/architecture-boundaries.md:41-56`, `specs/current/maintainability-roadmap.md:57-58`, `pnpm-workspace.yaml:1-3`
- Decision: Keep `apps/web/src/types.ts` as the UI/application type layer and move only daemon-facing DTOs/events/errors into `packages/contracts`. Web owns UI state and communicates with daemon through API DTOs and streaming events; current UI `AgentEvent` is a presentation union. Source: `specs/current/architecture-boundaries.md:13-27`, `apps/web/src/types.ts:32-39`, `apps/web/src/types.ts:150-179`, `apps/web/src/types.ts:215-252`
- Decision: Model the current daemon API before tightening behavior. Start with type contracts for `/api/chat`, `/api/proxy/stream`, project routes, conversation/message routes, file routes, artifacts, health, agents, skills, and design systems, then add runtime schemas in W4. Source: `specs/current/maintainability-roadmap.md:57-60`, `apps/daemon/server.js:200-269`, `apps/daemon/server.js:725-864`, `apps/daemon/server.js:868-884`, `apps/daemon/server.js:1188-1303`
- Decision: Define separate transport-level SSE unions and UI-level event unions. `/api/chat` transport events cover `start`, `agent`, `stdout`, `stderr`, `error`, and `end`; normalized agent payloads cover `status`, `text_delta`, `thinking_delta`, `thinking_start`, `tool_use`, `tool_result`, `usage`, and `raw`; web translation remains liberal for forward compatibility. Source: `apps/daemon/server.js:1035-1044`, `apps/daemon/server.js:1087-1180`, `apps/web/src/providers/daemon.ts:85-151`, `apps/web/src/providers/daemon.ts:178-228`, `apps/daemon/json-event-stream.js:35-91`
- Decision: Define a versioned SSE contract shape for future W8/W6 compatibility while preserving the existing event names during W2 adoption. Include a protocol version constant and typed event payloads; heartbeat, cancellation, and canonical task lifecycle events remain future extensions. Source: `specs/current/maintainability-roadmap.md:40-41`, `specs/current/maintainability-roadmap.md:57-64`, `apps/daemon/server.js:1035-1044`, `apps/daemon/server.js:1087-1180`
- Decision: Introduce a unified `ApiError` and `SseErrorEvent` type with `code`, `message`, `details`, `retryable`, `requestId`, and `taskId`, plus compatibility helpers for existing `{ error }` and `{ code, error }` responses. Current routes return multiple ad hoc shapes; W2 should make the target contract explicit. Source: `specs/current/maintainability-roadmap.md:39-40`, `apps/daemon/server.js:147-177`, `apps/daemon/server.js:200-205`, `apps/daemon/server.js:868-884`, `apps/daemon/server.js:1170-1180`
- Decision: Treat machine absolute paths as daemon-internal in public contracts. DTOs should use project-relative or logical paths; the existing `/api/chat` `start` event's `cwd` field should be typed as legacy/internal and removed from web-facing assumptions during adoption. Source: `specs/current/architecture-boundaries.md:58-64`, `apps/daemon/server.js:1087-1095`
- Decision: Model the current daemon API before tightening behavior. Start with type contracts for `/api/chat`, `/api/proxy/stream`, project routes, conversation/message routes, file routes, artifacts, health, agents, skills, and design systems, then add runtime schemas in W4. Source: `specs/current/maintainability-roadmap.md:57-60`, `apps/daemon/src/server.ts:200-269`, `apps/daemon/src/server.ts:725-864`, `apps/daemon/src/server.ts:868-884`, `apps/daemon/src/server.ts:1188-1303`
- Decision: Define separate transport-level SSE unions and UI-level event unions. `/api/chat` transport events cover `start`, `agent`, `stdout`, `stderr`, `error`, and `end`; normalized agent payloads cover `status`, `text_delta`, `thinking_delta`, `thinking_start`, `tool_use`, `tool_result`, `usage`, and `raw`; web translation remains liberal for forward compatibility. Source: `apps/daemon/src/server.ts:1035-1044`, `apps/daemon/src/server.ts:1087-1180`, `apps/web/src/providers/daemon.ts:85-151`, `apps/web/src/providers/daemon.ts:178-228`, `apps/daemon/src/json-event-stream.ts:35-91`
- Decision: Define a versioned SSE contract shape for future W8/W6 compatibility while preserving the existing event names during W2 adoption. Include a protocol version constant and typed event payloads; heartbeat, cancellation, and canonical task lifecycle events remain future extensions. Source: `specs/current/maintainability-roadmap.md:40-41`, `specs/current/maintainability-roadmap.md:57-64`, `apps/daemon/src/server.ts:1035-1044`, `apps/daemon/src/server.ts:1087-1180`
- Decision: Introduce a unified `ApiError` and `SseErrorEvent` type with `code`, `message`, `details`, `retryable`, `requestId`, and `taskId`, plus compatibility helpers for existing `{ error }` and `{ code, error }` responses. Current routes return multiple ad hoc shapes; W2 should make the target contract explicit. Source: `specs/current/maintainability-roadmap.md:39-40`, `apps/daemon/src/server.ts:147-177`, `apps/daemon/src/server.ts:200-205`, `apps/daemon/src/server.ts:868-884`, `apps/daemon/src/server.ts:1170-1180`
- Decision: Treat machine absolute paths as daemon-internal in public contracts. DTOs should use project-relative or logical paths; the existing `/api/chat` `start` event's `cwd` field should be typed as legacy/internal and removed from web-facing assumptions during adoption. Source: `specs/current/architecture-boundaries.md:58-64`, `apps/daemon/src/server.ts:1087-1095`
- Decision: W3's end state is a compiled TypeScript daemon runtime with a transitional `allowJs` phase. The daemon currently runs `node cli.js` and exposes `./cli.js` as its bin, so TypeScript entrypoint migration needs a deliberate build output and script/bin update. Source: `apps/daemon/package.json:6-13`, `package.json:9-24`
- Decision: Broaden typechecking from web-only to contracts, daemon, scripts, and e2e support. Root `typecheck` currently filters only `@open-design/web`; daemon has tests but no typecheck script; e2e already uses TypeScript configs and MJS/CJS operational files. Source: `package.json:19-25`, `apps/daemon/package.json:9-23`, `apps/web/tsconfig.json:2-23`, `e2e/package.json:6-12`, `e2e/playwright.config.ts:1-58`
- Decision: Migrate JavaScript/MJS/CJS files in dependency order: pure parsers/helpers, project/artifact helpers, DB/agent modules, server/CLI entrypoints, root scripts, then e2e scripts/reporters. This keeps each step verifiable and limits runtime-loader risk around Playwright reporter loading. Source: `apps/daemon/vitest.config.ts:1-8`, `apps/daemon/json-event-stream.js:35-91`, `e2e/playwright.config.ts:22-37`, `e2e/package.json:8-12`, `package.json:14-15`
- Decision: Migrate JavaScript/MJS/CJS files in dependency order: pure parsers/helpers, project/artifact helpers, DB/agent modules, server/CLI entrypoints, root scripts, then e2e scripts/reporters. This keeps each step verifiable and limits runtime-loader risk around Playwright reporter loading. Source: `apps/daemon/vitest.config.ts:1-8`, `apps/daemon/src/json-event-stream.ts:35-91`, `e2e/playwright.config.ts:22-37`, `e2e/package.json:8-12`, `package.json:14-15`
### Why this design
@ -162,8 +162,8 @@ flowchart LR
- Contracts: run `pnpm --filter @open-design/contracts typecheck`; add lightweight type-level coverage via exported example payloads or `tsc`-checked fixture files. Source: `specs/current/maintainability-roadmap.md:57-58`
- Web adoption: run `pnpm --filter @open-design/web typecheck` and existing web tests after importing shared DTO/SSE/error types. Source: `apps/web/package.json:6-10`, `apps/web/tsconfig.json:2-23`
- Daemon adoption: add and run `pnpm --filter @open-design/daemon typecheck`, then `pnpm --filter @open-design/daemon test`; daemon already uses Vitest with TypeScript config. Source: `apps/daemon/package.json:9-23`, `apps/daemon/vitest.config.ts:1-8`
- SSE compatibility: add or update parser/translator tests around `/api/chat` `start`, `agent`, `stdout`, `stderr`, `error`, and `end` frames plus normalized agent payloads. Source: `apps/web/src/providers/daemon.ts:85-151`, `apps/daemon/json-event-stream.js:35-91`
- Error model compatibility: add daemon route/helper tests for existing `{ error }` and `{ code, error }` inputs mapping into the new `ApiError` shape. Source: `apps/daemon/server.js:147-177`, `apps/daemon/server.js:200-205`, `apps/daemon/server.js:868-884`
- SSE compatibility: add or update parser/translator tests around `/api/chat` `start`, `agent`, `stdout`, `stderr`, `error`, and `end` frames plus normalized agent payloads. Source: `apps/web/src/providers/daemon.ts:85-151`, `apps/daemon/src/json-event-stream.ts:35-91`
- Error model compatibility: add daemon route/helper tests for existing `{ error }` and `{ code, error }` inputs mapping into the new `ApiError` shape. Source: `apps/daemon/src/server.ts:147-177`, `apps/daemon/src/server.ts:200-205`, `apps/daemon/src/server.ts:868-884`
- Runtime migration: after each TypeScript conversion batch, run daemon tests and root typecheck; after script/e2e migration, run `pnpm --filter @open-design/e2e test` and a Playwright reporter smoke run when feasible. Source: `package.json:19-25`, `e2e/package.json:6-12`, `e2e/playwright.config.ts:22-37`
### Pseudocode
@ -213,18 +213,18 @@ Flow:
### Interfaces / APIs
- `ChatRequest`: `{ agentId, message, systemPrompt?, projectId?, attachments?, model?, reasoning? }`, matching web post body and daemon handler reads. Source: `apps/web/src/providers/daemon.ts:57-77`, `apps/daemon/server.js:868-884`
- `ChatSseEvent`: discriminated union for `start`, `agent`, `stdout`, `stderr`, `error`, and `end`, with `cwd` treated as legacy/internal on `start`. Source: `apps/daemon/server.js:1035-1044`, `apps/daemon/server.js:1087-1180`
- `DaemonAgentPayload`: discriminated union for normalized agent payloads emitted inside `agent` events. Source: `apps/web/src/providers/daemon.ts:178-228`, `apps/daemon/json-event-stream.js:35-91`
- `ProxyStreamRequest` and `ProxySseEvent`: request fields `baseUrl`, `apiKey`, `model`, `systemPrompt`, and `messages`; events `start`, `delta`, `error`, and `end`. Source: `apps/daemon/server.js:1188-1303`
- `ApiError`: `{ code, message, details?, retryable?, requestId?, taskId? }`; `ApiErrorResponse`: `{ error: ApiError }`; compatibility helpers accept legacy string errors during migration. Source: `specs/current/maintainability-roadmap.md:39-40`, `apps/daemon/server.js:147-177`, `apps/daemon/server.js:200-205`
- Response envelopes: projects, conversations, messages, files, and file mutation responses should mirror the current daemon JSON shapes and reuse existing web DTO fields. Source: `apps/daemon/server.js:200-269`, `apps/daemon/server.js:325-424`, `apps/daemon/server.js:725-864`, `apps/web/src/types.ts:150-179`, `apps/web/src/types.ts:215-252`
- `ChatRequest`: `{ agentId, message, systemPrompt?, projectId?, attachments?, model?, reasoning? }`, matching web post body and daemon handler reads. Source: `apps/web/src/providers/daemon.ts:57-77`, `apps/daemon/src/server.ts:868-884`
- `ChatSseEvent`: discriminated union for `start`, `agent`, `stdout`, `stderr`, `error`, and `end`, with `cwd` treated as legacy/internal on `start`. Source: `apps/daemon/src/server.ts:1035-1044`, `apps/daemon/src/server.ts:1087-1180`
- `DaemonAgentPayload`: discriminated union for normalized agent payloads emitted inside `agent` events. Source: `apps/web/src/providers/daemon.ts:178-228`, `apps/daemon/src/json-event-stream.ts:35-91`
- `ProxyStreamRequest` and `ProxySseEvent`: request fields `baseUrl`, `apiKey`, `model`, `systemPrompt`, and `messages`; events `start`, `delta`, `error`, and `end`. Source: `apps/daemon/src/server.ts:1188-1303`
- `ApiError`: `{ code, message, details?, retryable?, requestId?, taskId? }`; `ApiErrorResponse`: `{ error: ApiError }`; compatibility helpers accept legacy string errors during migration. Source: `specs/current/maintainability-roadmap.md:39-40`, `apps/daemon/src/server.ts:147-177`, `apps/daemon/src/server.ts:200-205`
- Response envelopes: projects, conversations, messages, files, and file mutation responses should mirror the current daemon JSON shapes and reuse existing web DTO fields. Source: `apps/daemon/src/server.ts:200-269`, `apps/daemon/src/server.ts:325-424`, `apps/daemon/src/server.ts:725-864`, `apps/web/src/types.ts:150-179`, `apps/web/src/types.ts:215-252`
### Edge Cases
- Existing SSE consumers should continue ignoring unknown `agent` payloads, so new union members can be added safely. Source: `apps/web/src/providers/daemon.ts:178-228`
- Malformed or partial SSE frames should preserve current parser tolerance until W4 validation defines stricter behavior. Source: `apps/web/src/providers/daemon.ts:163-176`
- `/api/chat` currently emits terminal SSE errors and HTTP 400 JSON errors through different shapes; W2 should type both and allow incremental adoption. Source: `apps/daemon/server.js:868-884`, `apps/daemon/server.js:1170-1180`
- `/api/chat` currently emits terminal SSE errors and HTTP 400 JSON errors through different shapes; W2 should type both and allow incremental adoption. Source: `apps/daemon/src/server.ts:868-884`, `apps/daemon/src/server.ts:1170-1180`
- Playwright reporter loading currently points to a `.cjs` reporter path; migration needs a compiled JS reporter path or supported TS execution path. Source: `e2e/playwright.config.ts:22-37`
- The root `od` bin and daemon package bin currently point at JavaScript entrypoints; conversion to `.ts` requires a compiled output target before script/bin paths change. Source: `package.json:9-10`, `apps/daemon/package.json:6-13`
- Shared contracts should stay pure and free of Next, Express, Node filesystem/process APIs, browser APIs, SQLite, and daemon internals. Source: `specs/current/architecture-boundaries.md:41-56`
@ -283,8 +283,8 @@ Flow:
- `apps/web/package.json` and `apps/daemon/package.json` - added workspace dependencies on `@open-design/contracts` for boundary type adoption.
- `apps/web/src/types.ts` - re-exported shared chat, registry, project, file, and conversation DTOs while keeping UI/config-only types local.
- `apps/web/src/providers/daemon.ts` - typed `/api/chat` request construction, chat SSE frame handling, daemon agent payload translation, and unified SSE error payload reading with shared contracts.
- `apps/daemon/server.js` - added JSDoc contract imports, typed project/file response envelopes, typed chat/proxy request body reads, typed SSE send events, and shared-shape compatibility error helpers.
- `apps/daemon/server.js` - adopted `ApiErrorResponse`/`SseErrorPayload` shapes for chat, upload, project/file, and proxy stream error paths while preserving runtime behavior.
- `apps/daemon/src/server.ts` - added JSDoc contract imports, typed project/file response envelopes, typed chat/proxy request body reads, typed SSE send events, and shared-shape compatibility error helpers.
- `apps/daemon/src/server.ts` - adopted `ApiErrorResponse`/`SseErrorPayload` shapes for chat, upload, project/file, and proxy stream error paths while preserving runtime behavior.
- `apps/web/src/providers/sse.test.ts` - added coverage for unified daemon SSE error payload handling.
- `apps/daemon/sse-response.test.mjs` - added coverage for compatibility `ApiErrorResponse` construction.
- `apps/daemon/tsconfig.json` - added a strict daemon TypeScript foundation with `allowJs` for the current JavaScript/MJS transition and bundler resolution for workspace contract source imports.

View file

@ -35,10 +35,10 @@ The first-principles maintainability goals are:
| R3 | P0 | Runtime validation is incomplete at the daemon boundary. | Daemon requests can trigger local filesystem access, SQLite writes, and `child_process.spawn()`. | Type correctness alone cannot protect against malformed runtime input, path traversal, invalid agent IDs, or unsafe args. | Add schema validation at HTTP boundaries with Zod/TypeBox; centralize validation for workspace paths, task IDs, agent IDs, models, reasoning options, uploaded files, and command arguments. |
| R4 | P0 | Local capability security boundary needs explicit rules. | Daemon owns high-permission capabilities: local files, `.od`, project workspaces, agent CLIs, and logs. | Unsafe path handling, broad command execution, token leakage, and unintended workspace access become possible failure modes. | Treat daemon as a capability server: bind to localhost, use workspace/path allowlists, normalize and jail paths, allowlist agent commands, and redact sensitive output. |
| R5 | P0 | Agent process lifecycle needs a first-class manager. | `/api/chat` spawns multiple agent runtimes and streams output to the frontend. | Zombie processes, cancellation gaps, orphaned tasks, inconsistent exit handling, and concurrent process conflicts. | Introduce a process/task manager with task state machine, cancellation, timeout, cleanup, exit code capture, signal handling, and concurrency limits. |
| R6 | P1 | `server.js` is too monolithic. | `apps/daemon/server.js` contains many routes plus orchestration, filesystem logic, streaming, uploads, and artifact handling. | Harder to understand, test, and change; unrelated edits share the same file and increase regression risk. | Split into thin routes plus services/adapters: `routes/`, `services/`, `agents/`, `db/`, `fs/`, `streams/`, `artifacts/`. |
| R6 | P1 | `server.ts` is too monolithic. | `apps/daemon/src/server.ts` contains many routes plus orchestration, filesystem logic, streaming, uploads, and artifact handling. | Harder to understand, test, and change; unrelated edits share the same file and increase regression risk. | Split into thin routes plus services/adapters: `routes/`, `services/`, `agents/`, `db/`, `fs/`, `streams/`, `artifacts/`. |
| R7 | P1 | Error handling is inconsistent. | Handlers commonly use local `try/catch` and return ad hoc JSON errors. | UI receives inconsistent failures; logs lose context; task state can stall after partial failures. | Define a unified error model with `code`, `message`, `details`, `retryable`, and `requestId/taskId`; add centralized Express error middleware and adapter-level error mapping. |
| R8 | P1 | SSE protocol is under-specified. | Daemon manually writes `text/event-stream` events for agent output and status. | Frontend parsing is fragile; disconnect, heartbeat, terminal events, and error semantics can drift. | Version the SSE event contract and define canonical events such as `task.started`, `task.output`, `task.error`, `task.completed`, `task.cancelled`, and `heartbeat`. |
| R9 | P1 | SQLite schema and migration lifecycle need stronger guarantees. | `apps/daemon/db.js` owns local `better-sqlite3` tables and migrations. | Local user data upgrades can fail unpredictably; schema drift is hard to diagnose and recover. | Add explicit migration table, ordered forward migrations, startup migration checks, schema version logging, backup-before-migrate strategy, and migration tests. |
| R9 | P1 | SQLite schema and migration lifecycle need stronger guarantees. | `apps/daemon/src/db.ts` owns local `better-sqlite3` tables and migrations. | Local user data upgrades can fail unpredictably; schema drift is hard to diagnose and recover. | Add explicit migration table, ordered forward migrations, startup migration checks, schema version logging, backup-before-migrate strategy, and migration tests. |
| R10 | P1 | Test coverage is thin around daemon behavior. | Existing daemon tests focus on stream parsing and artifact manifest behavior; HTTP/DB/spawn flows have limited coverage. | Changes are validated by manual testing; regressions in filesystem, SQLite, SSE, or agent mocks can ship. | Build layered tests: shared contract tests, route integration tests, service unit tests, SQLite migration tests, SSE parser tests, and agent mock integration tests. |
| R11 | P1 | Logging and observability are insufficient for local runtime debugging. | Agent execution involves long-lived tasks, subprocess output, filesystem state, and frontend SSE consumption. | User issues are hard to reproduce; failures lack correlated context. | Add structured logs with `requestId`, `taskId`, `agentId`, `workspace`, exit code, and duration; separate app logs from agent output; redact secrets. |
| R12 | P2 | Configuration, port, and health behavior can become fragile. | Web proxies `/api/*` to daemon; dev startup coordinates Next.js and daemon ports. | Port conflicts, daemon-not-ready states, and mismatched environment variables can break startup or distribution. | Centralize config resolution; expose `/health`; add daemon readiness checks; make port selection and UI fallback deterministic. |

View file

@ -6,15 +6,15 @@ Runtime Adapter is the daemon layer responsible for adapting local AI agent CLIs
The current implementation is concentrated in:
- `apps/daemon/agents.js`: agent definitions, detection, model lists, argument construction, model validation.
- `apps/daemon/server.js`: `/api/chat` request orchestration, prompt composition, `spawn()` subprocesses, SSE forwarding.
- `apps/daemon/claude-stream.js`: parsing Claude Code structured JSONL output.
- `apps/daemon/json-event-stream.js`: parsing structured JSON/JSONL output from Codex, Gemini, OpenCode, and Cursor Agent.
- `apps/daemon/acp.js`: model detection and streaming session orchestration for the ACP JSON-RPC runtime.
- `apps/daemon/src/agents.ts`: agent definitions, detection, model lists, argument construction, model validation.
- `apps/daemon/src/server.ts`: `/api/chat` request orchestration, prompt composition, `spawn()` subprocesses, SSE forwarding.
- `apps/daemon/src/claude-stream.ts`: parsing Claude Code structured JSONL output.
- `apps/daemon/src/json-event-stream.ts`: parsing structured JSON/JSONL output from Codex, Gemini, OpenCode, and Cursor Agent.
- `apps/daemon/src/acp.ts`: model detection and streaming session orchestration for the ACP JSON-RPC runtime.
## Currently Supported Runtimes
`AGENT_DEFS` in `apps/daemon/agents.js` defines 8 local runtimes:
`AGENT_DEFS` in `apps/daemon/src/agents.ts` defines 8 local runtimes:
| id | Name | CLI | Output format | Model list source |
|---|---|---|---|---|
@ -61,7 +61,7 @@ The detection result includes:
## Runtime Flow
Actual execution happens in `POST /api/chat` in `apps/daemon/server.js`.
Actual execution happens in `POST /api/chat` in `apps/daemon/src/server.ts`.
Flow:
@ -102,7 +102,7 @@ These events are sent to the frontend through the SSE `agent` event.
### Codex / Gemini / OpenCode / Cursor Agent: Structured JSON Event Stream
These four runtimes currently use the unified `json-event-stream` output format, with stdout parsed by `apps/daemon/json-event-stream.js`.
These four runtimes currently use the unified `json-event-stream` output format, with stdout parsed by `apps/daemon/src/json-event-stream.ts`.
#### Codex
@ -198,7 +198,7 @@ Kimi uses:
kimi acp
```
The daemon starts an ACP session over stdio through `apps/daemon/acp.js`:
The daemon starts an ACP session over stdio through `apps/daemon/src/acp.ts`:
1. `initialize`
2. `session/new`

32
tools/AGENTS.md Normal file
View file

@ -0,0 +1,32 @@
# tools/AGENTS.md
Follow the root `AGENTS.md` first. This file only records module-level boundaries for `tools/`.
## Active tools
- `tools/dev` provides `@open-design/tools-dev` and the `tools-dev` bin. It is the only currently active local development lifecycle control plane.
- `pnpm tools-dev` manages daemon -> web -> desktop.
- `pnpm tools-dev run web` runs foreground daemon + web for the Playwright webServer flow.
- `pnpm tools-dev inspect desktop ...` inspects the desktop runtime through sidecar IPC.
## Placeholder tools
- `tools/pack` is the minimal placeholder for the future `tools-pack` workstream.
- Do not add a package manifest, root script, packaging command, or release/signing logic under `tools/pack` in this round.
- The package/build boundary of root `pnpm build` is intentionally unchanged in this round and should be handled by the future `tools-pack` task.
## Orchestration boundary
- Orchestration layers must consume primitives from `@open-design/sidecar-proto`, `@open-design/sidecar`, and `@open-design/platform`.
- Do not hand-build `--od-stamp-*` args, process-scan regexes, runtime tokens, process roles, or duplicate namespace/source args in `tools/dev`, future `tools/pack`, or packaged launchers.
- Port flags are authoritative inputs: `--daemon-port` and `--web-port`. Internal env vars are `OD_PORT` and `OD_WEB_PORT`; do not introduce `NEXT_PORT`.
## Common tools commands
```bash
pnpm --filter @open-design/tools-dev typecheck
pnpm --filter @open-design/tools-dev build
pnpm tools-dev status --json
pnpm tools-dev logs --json
pnpm tools-dev check
```

18
tools/dev/bin/tools-dev.mjs Executable file
View file

@ -0,0 +1,18 @@
#!/usr/bin/env node
import { existsSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
const entryDir = dirname(fileURLToPath(import.meta.url));
const distEntry = resolve(entryDir, '../dist/index.mjs');
const requiredDistEntries = [distEntry];
const missingDistEntries = requiredDistEntries.filter((entry) => !existsSync(entry));
if (missingDistEntries.length > 0) {
throw new Error(
`tools-dev dist entries not found: ${missingDistEntries.join(', ')}. Run "pnpm --filter @open-design/tools-dev build" first.`,
);
}
await import(pathToFileURL(distEntry).href);

View file

@ -0,0 +1,18 @@
import { build } from "esbuild";
await build({
banner: {
js: "#!/usr/bin/env node",
},
bundle: true,
entryNames: "[name]",
entryPoints: ["./src/index.ts"],
format: "esm",
outdir: "./dist",
outExtension: {
".js": ".mjs",
},
packages: "external",
platform: "node",
target: "node24",
});

29
tools/dev/package.json Normal file
View file

@ -0,0 +1,29 @@
{
"name": "@open-design/tools-dev",
"version": "0.1.0",
"private": true,
"type": "module",
"bin": {
"tools-dev": "./bin/tools-dev.mjs"
},
"scripts": {
"build": "node ./esbuild.config.mjs",
"dev": "tsx ./src/index.ts",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"@open-design/platform": "workspace:0.1.0",
"@open-design/sidecar": "workspace:0.1.0",
"@open-design/sidecar-proto": "workspace:0.1.0",
"cac": "6.7.14"
},
"devDependencies": {
"@types/node": "24.12.2",
"esbuild": "0.27.7",
"tsx": "4.21.0",
"typescript": "6.0.3"
},
"engines": {
"node": "~24"
}
}

186
tools/dev/src/config.ts Normal file
View file

@ -0,0 +1,186 @@
import { createRequire } from "node:module";
import path from "node:path";
import { fileURLToPath } from "node:url";
import {
APP_KEYS,
OPEN_DESIGN_SIDECAR_CONTRACT,
SIDECAR_ENV,
SIDECAR_SOURCES,
} from "@open-design/sidecar-proto";
import {
resolveAppIpcPath,
resolveAppRuntimePath,
resolveLogFilePath,
resolveNamespace,
resolveNamespaceRoot,
resolveSidecarBase,
resolveSourceRuntimeRoot,
} from "@open-design/sidecar";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ENTRY_DIR_NAME = path.basename(__dirname);
export const WORKSPACE_ROOT = path.resolve(__dirname, ENTRY_DIR_NAME === "dist" ? "../../.." : "../../..");
export const ALL_APPS = [APP_KEYS.DAEMON, APP_KEYS.WEB, APP_KEYS.DESKTOP] as const;
export const DEFAULT_START_APPS = [APP_KEYS.DAEMON, APP_KEYS.WEB, APP_KEYS.DESKTOP] as const;
export const DEFAULT_RUN_APPS = [APP_KEYS.DAEMON, APP_KEYS.WEB] as const;
export const DEFAULT_STOP_APPS = [APP_KEYS.DESKTOP, APP_KEYS.WEB, APP_KEYS.DAEMON] as const;
export type ToolDevAppName = (typeof ALL_APPS)[number];
export type ToolDevOptions = {
daemonPort?: number | string | null;
json?: boolean;
namespace?: string;
toolsDevRoot?: string;
webPort?: number | string | null;
};
export type ToolDevAppConfig = {
app: ToolDevAppName;
ipcPath: string;
latestLogPath: string;
logDir: string;
};
export type ToolDevConfig = {
apps: {
daemon: ToolDevAppConfig & {
sidecarEntryPath: string;
};
desktop: ToolDevAppConfig & {
electronBinaryPath: string;
mainEntryPath: string;
packageJsonPath: string;
};
web: ToolDevAppConfig & {
nextDistDir: string;
nextTsconfigPath: string;
sidecarEntryPath: string;
};
};
namespace: string;
namespaceRoot: string;
toolsDevRoot: string;
tsxCliPath: string;
workspaceRoot: string;
};
function resolveTsxCliPath(): string {
const require = createRequire(import.meta.url);
return require.resolve("tsx/cli");
}
function resolveElectronBinaryPath(workspaceRoot: string): string {
const packageJsonPath = path.join(workspaceRoot, "apps/desktop/package.json");
const require = createRequire(packageJsonPath);
const electron = require("electron") as unknown;
if (typeof electron === "string" && electron.length > 0) return electron;
return require.resolve("electron/cli.js");
}
function resolveAppConfig(options: {
app: ToolDevAppName;
namespace: string;
namespaceRoot: string;
toolsDevRoot: string;
}): ToolDevAppConfig {
return {
app: options.app,
ipcPath: resolveAppIpcPath({
app: options.app,
contract: OPEN_DESIGN_SIDECAR_CONTRACT,
namespace: options.namespace,
}),
latestLogPath: resolveLogFilePath({ runtimeRoot: options.namespaceRoot, app: options.app, contract: OPEN_DESIGN_SIDECAR_CONTRACT }),
logDir: path.dirname(resolveLogFilePath({ runtimeRoot: options.namespaceRoot, app: options.app, contract: OPEN_DESIGN_SIDECAR_CONTRACT })),
};
}
export function isToolDevAppName(value: string): value is ToolDevAppName {
return ALL_APPS.includes(value as ToolDevAppName);
}
export function resolveTargetApps(appName: string | undefined, defaults: readonly ToolDevAppName[]): ToolDevAppName[] {
if (appName == null) return [...defaults];
if (!isToolDevAppName(appName)) throw new Error(`unsupported tools-dev app: ${appName}`);
return [appName];
}
export function resolveStartApps(appName: string | undefined): ToolDevAppName[] {
if (appName == null) return [...DEFAULT_START_APPS];
if (!isToolDevAppName(appName)) throw new Error(`unsupported tools-dev app: ${appName}`);
if (appName === APP_KEYS.WEB) return [APP_KEYS.DAEMON, APP_KEYS.WEB];
if (appName === APP_KEYS.DESKTOP) return [APP_KEYS.DAEMON, APP_KEYS.WEB, APP_KEYS.DESKTOP];
return [APP_KEYS.DAEMON];
}
export function resolveRunApps(appName: string | undefined): ToolDevAppName[] {
if (appName == null) return [...DEFAULT_RUN_APPS];
return resolveStartApps(appName);
}
export function resolveStopApps(appName: string | undefined): ToolDevAppName[] {
if (appName == null) return [...DEFAULT_STOP_APPS];
if (!isToolDevAppName(appName)) throw new Error(`unsupported tools-dev app: ${appName}`);
if (appName === APP_KEYS.WEB) return [APP_KEYS.WEB, APP_KEYS.DAEMON];
if (appName === APP_KEYS.DESKTOP) return [APP_KEYS.DESKTOP];
return [APP_KEYS.DAEMON];
}
export function parsePortOption(value: number | string | null | undefined, optionName: string): number | null {
if (value == null || value === "") return null;
const parsed = Number(value);
if (!Number.isInteger(parsed) || parsed <= 0 || parsed > 65535) {
throw new Error(`${optionName} must be an integer between 1 and 65535`);
}
return parsed;
}
export function resolveToolDevConfig(options: ToolDevOptions = {}): ToolDevConfig {
const namespace = resolveNamespace({ namespace: options.namespace, env: process.env, contract: OPEN_DESIGN_SIDECAR_CONTRACT });
const toolsDevRoot = resolveSidecarBase({
base: options.toolsDevRoot ?? process.env[SIDECAR_ENV.BASE] ?? resolveSourceRuntimeRoot({
contract: OPEN_DESIGN_SIDECAR_CONTRACT,
projectRoot: WORKSPACE_ROOT,
source: SIDECAR_SOURCES.TOOLS_DEV,
}),
contract: OPEN_DESIGN_SIDECAR_CONTRACT,
env: process.env,
projectRoot: WORKSPACE_ROOT,
source: SIDECAR_SOURCES.TOOLS_DEV,
});
const namespaceRoot = resolveNamespaceRoot({ base: toolsDevRoot, namespace, contract: OPEN_DESIGN_SIDECAR_CONTRACT });
const daemon = resolveAppConfig({ app: APP_KEYS.DAEMON, namespace, namespaceRoot, toolsDevRoot });
const desktop = resolveAppConfig({ app: APP_KEYS.DESKTOP, namespace, namespaceRoot, toolsDevRoot });
const web = resolveAppConfig({ app: APP_KEYS.WEB, namespace, namespaceRoot, toolsDevRoot });
const desktopPackageJsonPath = path.join(WORKSPACE_ROOT, "apps/desktop/package.json");
return {
apps: {
daemon: {
...daemon,
sidecarEntryPath: path.join(WORKSPACE_ROOT, "apps/daemon/sidecar/index.ts"),
},
desktop: {
...desktop,
electronBinaryPath: resolveElectronBinaryPath(WORKSPACE_ROOT),
mainEntryPath: path.join(WORKSPACE_ROOT, "apps/desktop/dist/main/index.js"),
packageJsonPath: desktopPackageJsonPath,
},
web: {
...web,
nextDistDir: resolveAppRuntimePath({ app: APP_KEYS.WEB, namespaceRoot, fileName: "next", contract: OPEN_DESIGN_SIDECAR_CONTRACT }),
nextTsconfigPath: resolveAppRuntimePath({ app: APP_KEYS.WEB, namespaceRoot, fileName: "tsconfig.json", contract: OPEN_DESIGN_SIDECAR_CONTRACT }),
sidecarEntryPath: path.join(WORKSPACE_ROOT, "apps/web/sidecar/index.ts"),
},
},
namespace,
namespaceRoot,
toolsDevRoot,
tsxCliPath: resolveTsxCliPath(),
workspaceRoot: WORKSPACE_ROOT,
};
}

711
tools/dev/src/index.ts Normal file
View file

@ -0,0 +1,711 @@
import { spawn } from "node:child_process";
import { mkdir, open, writeFile, type FileHandle } from "node:fs/promises";
import path from "node:path";
import { cac } from "cac";
import {
APP_KEYS,
OPEN_DESIGN_SIDECAR_CONTRACT,
SIDECAR_ENV,
SIDECAR_MESSAGES,
SIDECAR_SOURCES,
type DaemonStatusSnapshot,
type DesktopClickResult,
type DesktopConsoleResult,
type DesktopEvalResult,
type DesktopScreenshotResult,
type DesktopStatusSnapshot,
type WebStatusSnapshot,
} from "@open-design/sidecar-proto";
import { createSidecarLaunchEnv, requestJsonIpc } from "@open-design/sidecar";
import {
collectProcessTreePids,
createPackageManagerInvocation,
createProcessStampArgs,
listProcessSnapshots,
matchesStampedProcess,
readLogTail,
spawnBackgroundProcess,
stopProcesses,
type StopProcessesResult,
} from "@open-design/platform";
import {
DEFAULT_START_APPS,
DEFAULT_STOP_APPS,
parsePortOption,
resolveRunApps,
resolveStartApps,
resolveStopApps,
resolveTargetApps,
resolveToolDevConfig,
type ToolDevAppName,
type ToolDevConfig,
type ToolDevOptions,
} from "./config.js";
import {
inspectDaemonRuntime,
inspectDesktopRuntime,
inspectWebRuntime,
waitForDaemonRuntime,
waitForDesktopRuntime,
waitForWebRuntime,
} from "./sidecar-client.js";
type CliOptions = ToolDevOptions & {
expr?: string;
parentPid?: number;
path?: string;
selector?: string;
timeout?: string;
};
const TOOLS_DEV_PARENT_PID_ENV = SIDECAR_ENV.TOOLS_DEV_PARENT_PID;
function formatError(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function exitWithError(error: unknown): never {
process.stderr.write(`${formatError(error)}\n`);
process.exit(1);
}
process.on("uncaughtException", exitWithError);
process.on("unhandledRejection", exitWithError);
function printJson(payload: unknown): void {
process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
}
function output(payload: unknown, options: CliOptions = {}): void {
if (typeof payload === "string" && options.json !== true) {
process.stdout.write(`${payload}\n`);
return;
}
printJson(payload);
}
function runtimeLookup(config: ToolDevConfig) {
return { base: config.toolsDevRoot, namespace: config.namespace };
}
function appConfig(config: ToolDevConfig, appName: ToolDevAppName) {
return config.apps[appName];
}
function urlPort(url: string): string {
const parsed = new URL(url);
if (parsed.port) return parsed.port;
return parsed.protocol === "https:" ? "443" : "80";
}
function statusMatchesForcedPort(url: string | null | undefined, forcedPort: number | null): boolean {
return forcedPort == null || (url != null && urlPort(url) === String(forcedPort));
}
async function openAppLog(config: ToolDevConfig, appName: ToolDevAppName): Promise<FileHandle> {
const logPath = appConfig(config, appName).latestLogPath;
await mkdir(path.dirname(logPath), { recursive: true });
return await open(logPath, "a");
}
async function runLoggedCommand(request: {
args: string[];
command: string;
cwd: string;
env?: NodeJS.ProcessEnv;
logFd: number;
}): Promise<void> {
const child = spawn(request.command, request.args, {
cwd: request.cwd,
env: request.env,
stdio: ["ignore", request.logFd, request.logFd],
windowsHide: process.platform === "win32",
});
await new Promise<void>((resolveRun, rejectRun) => {
child.once("error", rejectRun);
child.once("exit", (code, signal) => {
if (code === 0) {
resolveRun();
return;
}
rejectRun(new Error(`command failed: ${request.command} ${request.args.join(" ")} (${signal ?? code})`));
});
});
}
function createAppStamp(config: ToolDevConfig, appName: ToolDevAppName) {
const currentAppConfig = appConfig(config, appName);
const stamp = {
app: appName,
ipc: currentAppConfig.ipcPath,
mode: "dev" as const,
namespace: config.namespace,
source: SIDECAR_SOURCES.TOOLS_DEV,
};
return {
args: createProcessStampArgs(stamp, OPEN_DESIGN_SIDECAR_CONTRACT),
env: createSidecarLaunchEnv({
base: config.toolsDevRoot,
contract: OPEN_DESIGN_SIDECAR_CONTRACT,
stamp,
}),
stamp,
};
}
async function findAppProcessTree(config: ToolDevConfig, appName: ToolDevAppName) {
const processes = await listProcessSnapshots();
const rootPids = processes
.filter((processInfo) =>
matchesStampedProcess(processInfo, {
app: appName,
mode: "dev",
namespace: config.namespace,
source: SIDECAR_SOURCES.TOOLS_DEV,
}, OPEN_DESIGN_SIDECAR_CONTRACT),
)
.map((processInfo) => processInfo.pid);
const pids = collectProcessTreePids(processes, rootPids);
return { pids, rootPids };
}
async function waitForAppProcessExit(config: ToolDevConfig, appName: ToolDevAppName, timeoutMs = 5000): Promise<number[]> {
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
const current = await findAppProcessTree(config, appName);
if (current.pids.length === 0) return [];
await new Promise((resolveWait) => setTimeout(resolveWait, 120));
}
return (await findAppProcessTree(config, appName)).pids;
}
async function assertNoStaleActiveProcess(config: ToolDevConfig, appName: ToolDevAppName): Promise<void> {
const active = await findAppProcessTree(config, appName);
if (active.pids.length > 0) {
throw new Error(`${appName} has active stamped processes but no reachable IPC status; run tools-dev stop ${appName} first`);
}
}
async function spawnSidecarRuntime(request: {
appName: typeof APP_KEYS.DAEMON | typeof APP_KEYS.WEB;
config: ToolDevConfig;
env: NodeJS.ProcessEnv;
logHandle: FileHandle;
}): Promise<{ pid: number }> {
const { args: stampArgs, env } = createAppStamp(request.config, request.appName);
const sidecarConfig = request.config.apps[request.appName];
const spawned = await spawnBackgroundProcess({
args: [request.config.tsxCliPath, sidecarConfig.sidecarEntryPath, ...stampArgs],
command: process.execPath,
cwd: request.config.workspaceRoot,
detached: true,
env: {
...process.env,
...env,
...request.env,
},
logFd: request.logHandle.fd,
});
return { pid: spawned.pid };
}
async function spawnDaemonRuntime(config: ToolDevConfig, options: CliOptions): Promise<{ pid: number }> {
const daemonPort = parsePortOption(options.daemonPort, "--daemon-port");
const logHandle = await openAppLog(config, APP_KEYS.DAEMON);
try {
await logHandle.write(`\n[tools-dev] launching daemon at ${new Date().toISOString()}\n`);
return await spawnSidecarRuntime({
appName: APP_KEYS.DAEMON,
config,
env: {
[SIDECAR_ENV.DAEMON_PORT]: String(daemonPort ?? 0),
...(options.parentPid == null ? {} : { [TOOLS_DEV_PARENT_PID_ENV]: String(options.parentPid) }),
},
logHandle,
});
} finally {
await logHandle.close();
}
}
async function spawnWebRuntime(config: ToolDevConfig, options: CliOptions): Promise<{ pid: number }> {
const daemonStatus = await waitForDaemonRuntime(runtimeLookup(config));
if (daemonStatus.url == null) throw new Error("daemon must be running before web starts");
const webPort = parsePortOption(options.webPort, "--web-port");
const daemonPort = urlPort(daemonStatus.url);
const logHandle = await openAppLog(config, APP_KEYS.WEB);
try {
await writeWebDevTsconfig(config);
await logHandle.write(`\n[tools-dev] launching web at ${new Date().toISOString()}\n`);
await logHandle.write(`[tools-dev] proxying web API requests to daemon port ${daemonPort}\n`);
return await spawnSidecarRuntime({
appName: APP_KEYS.WEB,
config,
env: {
[SIDECAR_ENV.DAEMON_PORT]: daemonPort,
[SIDECAR_ENV.WEB_DIST_DIR]: config.apps.web.nextDistDir,
[SIDECAR_ENV.WEB_TSCONFIG_PATH]: config.apps.web.nextTsconfigPath,
[SIDECAR_ENV.WEB_PORT]: String(webPort ?? 0),
PORT: String(webPort ?? 0),
...(options.parentPid == null ? {} : { [TOOLS_DEV_PARENT_PID_ENV]: String(options.parentPid) }),
},
logHandle,
});
} finally {
await logHandle.close();
}
}
async function buildDesktop(config: ToolDevConfig, logHandle: FileHandle): Promise<void> {
await logHandle.write(`\n[tools-dev] building @open-design/desktop at ${new Date().toISOString()}\n`);
const invocation = createPackageManagerInvocation(["--filter", "@open-design/desktop", "build"], process.env);
await runLoggedCommand({
args: invocation.args,
command: invocation.command,
cwd: config.workspaceRoot,
env: process.env,
logFd: logHandle.fd,
});
}
async function writeWebDevTsconfig(config: ToolDevConfig): Promise<void> {
const webRoot = path.join(config.workspaceRoot, "apps/web");
const tsconfigPath = config.apps.web.nextTsconfigPath;
const tsconfigDir = path.dirname(tsconfigPath);
const sourceTsconfig = path.join(webRoot, "tsconfig.json");
const relativeSourceTsconfig = path.relative(tsconfigDir, sourceTsconfig) || "./tsconfig.json";
await mkdir(tsconfigDir, { recursive: true });
await writeFile(
tsconfigPath,
`${JSON.stringify({
extends: relativeSourceTsconfig,
compilerOptions: {
plugins: [{ name: "next" }],
},
}, null, 2)}\n`,
"utf8",
);
}
async function spawnDesktopRuntime(config: ToolDevConfig, options: CliOptions): Promise<{ pid: number }> {
const { args: stampArgs, env } = createAppStamp(config, APP_KEYS.DESKTOP);
const logHandle = await openAppLog(config, APP_KEYS.DESKTOP);
try {
await buildDesktop(config, logHandle);
await logHandle.write(`[tools-dev] launching desktop at ${new Date().toISOString()}\n`);
const spawned = await spawnBackgroundProcess({
args: [config.apps.desktop.mainEntryPath, ...stampArgs],
command: config.apps.desktop.electronBinaryPath,
cwd: config.workspaceRoot,
detached: true,
env: {
...process.env,
...env,
...(options.parentPid == null ? {} : { [TOOLS_DEV_PARENT_PID_ENV]: String(options.parentPid) }),
},
logFd: logHandle.fd,
});
return { pid: spawned.pid };
} finally {
await logHandle.close();
}
}
async function startDaemon(config: ToolDevConfig, options: CliOptions) {
const daemonPort = parsePortOption(options.daemonPort, "--daemon-port");
const existing = await inspectDaemonRuntime(runtimeLookup(config));
if (existing?.url != null && statusMatchesForcedPort(existing.url, daemonPort)) {
return { app: APP_KEYS.DAEMON, created: false, logPath: config.apps.daemon.latestLogPath, status: existing };
}
if (existing?.url != null) {
throw new Error(`${APP_KEYS.DAEMON} is already running in namespace ${config.namespace} at ${existing.url}; stop it or choose another namespace`);
}
await assertNoStaleActiveProcess(config, APP_KEYS.DAEMON);
const spawned = await spawnDaemonRuntime(config, options);
try {
const status = await waitForDaemonRuntime(runtimeLookup(config));
return {
app: APP_KEYS.DAEMON,
created: true,
logPath: config.apps.daemon.latestLogPath,
pid: spawned.pid,
status,
};
} catch (error) {
await stopApp(config, APP_KEYS.DAEMON).catch(() => undefined);
throw error;
}
}
async function startWeb(config: ToolDevConfig, options: CliOptions) {
const webPort = parsePortOption(options.webPort, "--web-port");
const existing = await inspectWebRuntime(runtimeLookup(config));
if (existing?.url != null && statusMatchesForcedPort(existing.url, webPort)) {
return { app: APP_KEYS.WEB, created: false, logPath: config.apps.web.latestLogPath, status: existing };
}
if (existing?.url != null) {
throw new Error(`${APP_KEYS.WEB} is already running in namespace ${config.namespace} at ${existing.url}; stop it or choose another namespace`);
}
await assertNoStaleActiveProcess(config, APP_KEYS.WEB);
const spawned = await spawnWebRuntime(config, options);
try {
const status = await waitForWebRuntime(runtimeLookup(config));
return {
app: APP_KEYS.WEB,
created: true,
logPath: config.apps.web.latestLogPath,
pid: spawned.pid,
status,
};
} catch (error) {
await stopApp(config, APP_KEYS.WEB).catch(() => undefined);
throw error;
}
}
async function startDesktop(config: ToolDevConfig, options: CliOptions) {
const existing = await inspectDesktopRuntime(runtimeLookup(config));
if (existing != null) {
return { app: APP_KEYS.DESKTOP, created: false, logPath: config.apps.desktop.latestLogPath, status: existing };
}
await assertNoStaleActiveProcess(config, APP_KEYS.DESKTOP);
const spawned = await spawnDesktopRuntime(config, options);
try {
const status = await waitForDesktopRuntime(runtimeLookup(config));
return {
app: APP_KEYS.DESKTOP,
created: true,
logPath: config.apps.desktop.latestLogPath,
pid: spawned.pid,
status,
};
} catch (error) {
await stopApp(config, APP_KEYS.DESKTOP).catch(() => undefined);
throw error;
}
}
async function startApp(config: ToolDevConfig, appName: ToolDevAppName, options: CliOptions) {
switch (appName) {
case APP_KEYS.DAEMON:
return await startDaemon(config, options);
case APP_KEYS.WEB:
return await startWeb(config, options);
case APP_KEYS.DESKTOP:
return await startDesktop(config, options);
}
}
async function requestAppShutdown(config: ToolDevConfig, appName: ToolDevAppName): Promise<boolean> {
try {
await requestJsonIpc(appConfig(config, appName).ipcPath, { type: SIDECAR_MESSAGES.SHUTDOWN }, { timeoutMs: 1500 });
return true;
} catch {
return false;
}
}
function stoppedByGracefulResult(matchedPids: number[]): StopProcessesResult {
return {
alreadyStopped: matchedPids.length === 0,
forcedPids: [],
matchedPids,
remainingPids: [],
stoppedPids: matchedPids,
};
}
async function stopApp(config: ToolDevConfig, appName: ToolDevAppName) {
const before = await findAppProcessTree(config, appName);
const gracefulRequested = await requestAppShutdown(config, appName);
const remainingAfterGraceful = gracefulRequested
? await waitForAppProcessExit(config, appName)
: before.pids;
if (remainingAfterGraceful.length === 0) {
return {
app: appName,
status: before.pids.length === 0 ? "not-running" : "stopped",
stop: stoppedByGracefulResult(before.pids),
via: gracefulRequested ? "ipc" : "process-scan",
};
}
const stop = await stopProcesses(remainingAfterGraceful);
return {
app: appName,
status: stop.remainingPids.length === 0 ? "stopped" : "partial",
stop,
via: gracefulRequested ? "ipc+fallback" : "fallback",
};
}
async function inspectAppStatus(config: ToolDevConfig, appName: ToolDevAppName) {
if (appName === APP_KEYS.DAEMON) {
const status = await inspectDaemonRuntime(runtimeLookup(config));
if (status != null) return status;
const active = await findAppProcessTree(config, appName);
return { pid: active.rootPids[0] ?? null, state: active.pids.length > 0 ? "starting" : "idle", url: null } satisfies DaemonStatusSnapshot;
}
if (appName === APP_KEYS.WEB) {
const status = await inspectWebRuntime(runtimeLookup(config));
if (status != null) return status;
const active = await findAppProcessTree(config, appName);
return { pid: active.rootPids[0] ?? null, state: active.pids.length > 0 ? "starting" : "idle", url: null } satisfies WebStatusSnapshot;
}
const status = await inspectDesktopRuntime(runtimeLookup(config));
if (status != null) return status;
const active = await findAppProcessTree(config, appName);
return { pid: active.rootPids[0] ?? null, state: active.pids.length > 0 ? "unknown" : "idle", url: null };
}
function summarizeStatus(apps: Record<ToolDevAppName, any>): string {
const states = Object.values(apps).map((entry) => entry?.state);
if (states.every((state) => state === "idle")) return "not-running";
if (states.every((state) => state === "running")) return "running";
return "partial";
}
async function status(config: ToolDevConfig, appName: string | undefined) {
const targets = resolveTargetApps(appName, DEFAULT_START_APPS);
if (targets.length === 1) return await inspectAppStatus(config, targets[0]);
const apps = Object.fromEntries(
await Promise.all(targets.map(async (target) => [target, await inspectAppStatus(config, target)] as const)),
) as Record<ToolDevAppName, unknown>;
return { apps, namespace: config.namespace, status: summarizeStatus(apps) };
}
async function restartTargets(config: ToolDevConfig, appName: string | undefined, options: CliOptions) {
const stopTargets = resolveStopApps(appName);
const startTargets = resolveStartApps(appName);
return {
stop: await runSequential(stopTargets, (target) => stopApp(config, target)),
start: await runSequential(startTargets, (target) => startApp(config, target, options)),
};
}
async function readLogs(config: ToolDevConfig, appName: ToolDevAppName) {
const logPath = appConfig(config, appName).latestLogPath;
return { app: appName, lines: await readLogTail(logPath, 200), logPath };
}
type LogResult = Awaited<ReturnType<typeof readLogs>>;
function isLogResult(value: LogResult | Record<string, LogResult>): value is LogResult {
return Array.isArray((value as LogResult).lines);
}
function printLogs(result: LogResult | Record<string, LogResult>, options: CliOptions) {
if (options.json === true) {
printJson(result);
return;
}
const entries: Array<[string, LogResult]> = isLogResult(result) ? [[result.app, result]] : Object.entries(result);
for (const [appName, entry] of entries) {
process.stdout.write(`[${appName}] ${entry.logPath}\n`);
process.stdout.write(entry.lines.length > 0 ? `${entry.lines.join("\n")}\n` : "(no log lines)\n");
}
}
function parseTimeoutMs(value: string | undefined): number | undefined {
if (value == null) return undefined;
const seconds = Number(value);
if (!Number.isFinite(seconds) || seconds <= 0) throw new Error("--timeout must be a positive number of seconds");
return seconds * 1000;
}
async function inspectDesktop(config: ToolDevConfig, target: string | undefined, options: CliOptions) {
const operation = target ?? "status";
const timeoutMs = parseTimeoutMs(options.timeout) ?? 30000;
switch (operation) {
case "status":
return (await inspectDesktopRuntime(runtimeLookup(config), 1000)) ?? ({ state: "idle" } satisfies DesktopStatusSnapshot);
case "eval":
if (options.expr == null) throw new Error("--expr is required for desktop eval");
return await requestJsonIpc<DesktopEvalResult>(
config.apps.desktop.ipcPath,
{ input: { expression: options.expr }, type: SIDECAR_MESSAGES.EVAL },
{ timeoutMs },
);
case "screenshot":
if (options.path == null) throw new Error("--path is required for desktop screenshot");
return await requestJsonIpc<DesktopScreenshotResult>(
config.apps.desktop.ipcPath,
{ input: { path: options.path }, type: SIDECAR_MESSAGES.SCREENSHOT },
{ timeoutMs },
);
case "console":
return await requestJsonIpc<DesktopConsoleResult>(config.apps.desktop.ipcPath, { type: SIDECAR_MESSAGES.CONSOLE }, { timeoutMs });
case "click":
if (options.selector == null) throw new Error("--selector is required for desktop click");
return await requestJsonIpc<DesktopClickResult>(
config.apps.desktop.ipcPath,
{ input: { selector: options.selector }, type: SIDECAR_MESSAGES.CLICK },
{ timeoutMs },
);
default:
throw new Error(`unsupported desktop inspect target: ${operation}`);
}
}
async function inspect(config: ToolDevConfig, appName: string, target: string | undefined, options: CliOptions) {
if (appName === APP_KEYS.DAEMON) {
if (target != null && target !== "status") throw new Error(`unsupported daemon inspect target: ${target}`);
return (await inspectDaemonRuntime(runtimeLookup(config), 1000)) ?? ({ state: "idle", url: null } satisfies DaemonStatusSnapshot);
}
if (appName === APP_KEYS.WEB) {
if (target != null && target !== "status") throw new Error(`unsupported web inspect target: ${target}`);
return (await inspectWebRuntime(runtimeLookup(config), 1000)) ?? ({ state: "idle", url: null } satisfies WebStatusSnapshot);
}
if (appName !== APP_KEYS.DESKTOP) throw new Error(`unsupported tools-dev app: ${appName}`);
return await inspectDesktop(config, target, options);
}
async function runSequential<T>(targets: readonly ToolDevAppName[], operation: (target: ToolDevAppName) => Promise<T>) {
const result: Partial<Record<ToolDevAppName, T>> = {};
for (const target of targets) result[target] = await operation(target);
return result;
}
function stopOrderFor(targets: readonly ToolDevAppName[]): ToolDevAppName[] {
const selected = new Set(targets);
return DEFAULT_STOP_APPS.filter((target) => selected.has(target));
}
async function runForeground(config: ToolDevConfig, appName: string | undefined, options: CliOptions) {
const targets = resolveRunApps(appName);
const foregroundOptions = { ...options, parentPid: process.pid };
const started = await runSequential(targets, (target) => startApp(config, target, foregroundOptions));
output({ mode: "foreground", started }, options);
let shuttingDown = false;
const keepAlive = setInterval(() => undefined, 60_000);
await new Promise<void>((resolveDone) => {
const shutdown = () => {
if (shuttingDown) return;
shuttingDown = true;
clearInterval(keepAlive);
void runSequential(stopOrderFor(targets), (target) => stopApp(config, target)).finally(resolveDone);
};
for (const sig of ["SIGINT", "SIGTERM"] as const) {
process.on(sig, shutdown);
}
});
}
const cli = cac("tools-dev");
function addSharedOptions(command: ReturnType<typeof cli.command>) {
return command
.option("--namespace <name>", "runtime namespace (default: default)")
.option("--tools-dev-root <path>", "tools-dev runtime root")
.option("--json", "print JSON");
}
function addPortOptions(command: ReturnType<typeof cli.command>) {
return command
.option("--daemon-port <port>", "force daemon port; conflict quick-fails")
.option("--web-port <port>", "force web port; conflict quick-fails");
}
addPortOptions(addSharedOptions(cli.command("start [app]", "Start daemon, web, desktop, or all when app is omitted"))).action(
async (appName: string | undefined, options: CliOptions) => {
const config = resolveToolDevConfig(options);
const targets = resolveStartApps(appName);
const result = await runSequential(targets, (target) => startApp(config, target, options));
output(result, options);
},
);
addPortOptions(addSharedOptions(cli.command("run [app]", "Start apps and keep this command alive until interrupted"))).action(
async (appName: string | undefined, options: CliOptions) => {
await runForeground(resolveToolDevConfig(options), appName, options);
},
);
addSharedOptions(cli.command("status [app]", "Show app status for daemon, web, desktop, or all")).action(
async (appName: string | undefined, options: CliOptions) => {
output(await status(resolveToolDevConfig(options), appName), options);
},
);
addSharedOptions(cli.command("stop [app]", "Stop daemon, web, desktop, or all when app is omitted")).action(
async (appName: string | undefined, options: CliOptions) => {
const config = resolveToolDevConfig(options);
const targets = resolveStopApps(appName);
const result = await runSequential(targets, (target) => stopApp(config, target));
output(result, options);
},
);
addPortOptions(addSharedOptions(cli.command("restart [app]", "Restart daemon, web, desktop, or all when app is omitted"))).action(
async (appName: string | undefined, options: CliOptions) => {
output(await restartTargets(resolveToolDevConfig(options), appName, options), options);
},
);
addSharedOptions(cli.command("logs [app]", "Show log tail for daemon, web, desktop, or all")).action(
async (appName: string | undefined, options: CliOptions) => {
const config = resolveToolDevConfig(options);
const targets = resolveTargetApps(appName, DEFAULT_START_APPS);
const result = targets.length === 1
? await readLogs(config, targets[0])
: Object.fromEntries(await Promise.all(targets.map(async (target) => [target, await readLogs(config, target)] as const)));
printLogs(result, options);
},
);
addSharedOptions(
cli.command("inspect <app> [target]", "Inspect daemon/web status or desktop status/eval/screenshot/console/click"),
)
.option("--expr <js>", "JavaScript expression for desktop eval")
.option("--path <file>", "Output path for desktop screenshot")
.option("--selector <css>", "CSS selector for desktop click")
.option("--timeout <seconds>", "Desktop inspect timeout in seconds")
.action(async (appName: string, target: string | undefined, options: CliOptions) => {
output(await inspect(resolveToolDevConfig(options), appName, target, options), options);
});
addSharedOptions(cli.command("check [app]", "Print status and recent logs for quick diagnostics")).action(
async (appName: string | undefined, options: CliOptions) => {
const config = resolveToolDevConfig(options);
const targets = resolveTargetApps(appName, DEFAULT_START_APPS);
const apps = Object.fromEntries(
await Promise.all(targets.map(async (target) => [target, await inspectAppStatus(config, target)] as const)),
);
const logs = Object.fromEntries(
await Promise.all(targets.map(async (target) => [target, await readLogs(config, target)] as const)),
);
output({ apps, logs, namespace: config.namespace }, options);
},
);
cli.help();
const rawCliArgs = process.argv.slice(2);
const cliArgs = rawCliArgs[0] === "--" ? rawCliArgs.slice(1) : rawCliArgs;
process.argv.splice(2, process.argv.length - 2, ...cliArgs);
if (cliArgs.length === 0 || (cliArgs[0]?.startsWith("-") && cliArgs[0] !== "--help" && cliArgs[0] !== "-h")) {
process.argv.splice(2, 0, "start");
}
cli.parse();

View file

@ -0,0 +1,80 @@
import {
APP_KEYS,
OPEN_DESIGN_SIDECAR_CONTRACT,
SIDECAR_MESSAGES,
type DaemonStatusSnapshot,
type DesktopStatusSnapshot,
type WebStatusSnapshot,
} from "@open-design/sidecar-proto";
import { requestJsonIpc, resolveAppIpcPath } from "@open-design/sidecar";
export type AppRuntimeLookup = {
base: string;
namespace: string;
};
export function resolveDaemonIpcPath(runtime: AppRuntimeLookup): string {
return resolveAppIpcPath({ app: APP_KEYS.DAEMON, contract: OPEN_DESIGN_SIDECAR_CONTRACT, namespace: runtime.namespace });
}
export function resolveWebIpcPath(runtime: AppRuntimeLookup): string {
return resolveAppIpcPath({ app: APP_KEYS.WEB, contract: OPEN_DESIGN_SIDECAR_CONTRACT, namespace: runtime.namespace });
}
export function resolveDesktopIpcPath(runtime: AppRuntimeLookup): string {
return resolveAppIpcPath({ app: APP_KEYS.DESKTOP, contract: OPEN_DESIGN_SIDECAR_CONTRACT, namespace: runtime.namespace });
}
export async function inspectDaemonRuntime(runtime: AppRuntimeLookup, timeoutMs = 800): Promise<DaemonStatusSnapshot | null> {
try {
return await requestJsonIpc<DaemonStatusSnapshot>(resolveDaemonIpcPath(runtime), { type: SIDECAR_MESSAGES.STATUS }, { timeoutMs });
} catch {
return null;
}
}
export async function waitForDaemonRuntime(runtime: AppRuntimeLookup, timeoutMs = 35000): Promise<DaemonStatusSnapshot> {
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
const snapshot = await inspectDaemonRuntime(runtime, 800);
if (snapshot?.url != null) return snapshot;
await new Promise((resolveWait) => setTimeout(resolveWait, 150));
}
throw new Error("daemon did not expose status in time");
}
export async function inspectWebRuntime(runtime: AppRuntimeLookup, timeoutMs = 800): Promise<WebStatusSnapshot | null> {
try {
return await requestJsonIpc<WebStatusSnapshot>(resolveWebIpcPath(runtime), { type: SIDECAR_MESSAGES.STATUS }, { timeoutMs });
} catch {
return null;
}
}
export async function waitForWebRuntime(runtime: AppRuntimeLookup, timeoutMs = 35000): Promise<WebStatusSnapshot> {
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
const snapshot = await inspectWebRuntime(runtime, 800);
if (snapshot?.url != null) return snapshot;
await new Promise((resolveWait) => setTimeout(resolveWait, 150));
}
throw new Error("web did not expose status in time");
}
export async function inspectDesktopRuntime(runtime: AppRuntimeLookup, timeoutMs = 800): Promise<DesktopStatusSnapshot | null> {
try {
return await requestJsonIpc<DesktopStatusSnapshot>(resolveDesktopIpcPath(runtime), { type: SIDECAR_MESSAGES.STATUS }, { timeoutMs });
} catch {
return null;
}
}
export async function waitForDesktopRuntime(runtime: AppRuntimeLookup, timeoutMs = 15000): Promise<DesktopStatusSnapshot> {
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
const snapshot = await inspectDesktopRuntime(runtime, 800);
if (snapshot != null) return snapshot;
await new Promise((resolveWait) => setTimeout(resolveWait, 150));
}
throw new Error("desktop did not expose status in time");
}

18
tools/dev/tsconfig.json Normal file
View file

@ -0,0 +1,18 @@
{
"compilerOptions": {
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"isolatedModules": true,
"lib": ["ES2024", "DOM"],
"module": "NodeNext",
"moduleResolution": "NodeNext",
"noEmit": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"strict": false,
"target": "ES2024",
"types": ["node"]
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "esbuild.config.mjs"]
}

5
tools/pack/README.md Normal file
View file

@ -0,0 +1,5 @@
# tools/pack
Minimal placeholder for the future `tools-pack` workstream.
No active packaging command, release logic, package manifest, or root script lives here yet.