diff --git a/.agents/skills/plan-canvas/SKILL.md b/.agents/skills/plan-canvas/SKILL.md new file mode 100644 index 000000000..72ea5aef6 --- /dev/null +++ b/.agents/skills/plan-canvas/SKILL.md @@ -0,0 +1,152 @@ +--- +name: plan-canvas +description: Open plans and HTML artifacts in a local browser canvas where the human annotates elements, chats, and approves or requests changes without leaving the page. Use when presenting a plan for review, or when feedback like "move this, change that" is easier pointed at than typed. +metadata: + origin: ECC +--- + +# Plan Canvas + +Review loop for plans and visual artifacts: you write the artifact, the human +reviews it in the browser — annotating the exact element they mean, chatting, +and delivering an **Approve plan / Request changes** verdict — while you block +on a single CLI call that returns their feedback as JSON. + +Inspired by [lavish-axi](https://github.com/kunchenguid/lavish-axi); rebuilt +ECC-native around the `/plan` confirmation gate, with zero dependencies. + +## When to Use + +- You just wrote a plan artifact (`.claude/plans/*.plan.md` from `/plan`) and + need the CONFIRM/approve decision — the canvas verdict replaces a typed + "yes/proceed". +- The user should *point at* what to change: reviewing designs, comparisons, + reports, or any local `.md` / `.html` artifact. +- The user asks for `/plan-canvas`, a visual review, or "open it in the browser". + +Do NOT use for: code review of diffs (`/code-review`), running web apps, or +remote URLs. The canvas serves local artifact files only. + +## How It Works + +Invoke the CLI as `ecc-plan-canvas` — the bin shipped by the `ecc-universal` +package (on PATH after a global/plugin install; `node "$CLAUDE_PLUGIN_ROOT/scripts/plan-canvas.js"` +also works for plugin installs). Run it from the project you are reviewing in; +it works from any working directory. It manages a detached loopback server +(`127.0.0.1:4517`) shared by all sessions, keyed by artifact path — no session +ids to track. + +The workflow is a plain CLI-plus-JSON loop, so it is model- and harness-agnostic: +any agent that can run a shell command and read stdout drives it the same way +(Claude Code, Codex, Cursor, Gemini, OpenCode, Copilot). Trigger it however your +harness surfaces skills — e.g. `/plan-canvas` in Claude Code, `$plan-canvas` in +Codex — or just run the `ecc-plan-canvas` commands directly. + +```bash +# 1. Open the artifact in the user's browser (returns immediately) +ecc-plan-canvas open .claude/plans/feature.plan.md + +# 2. Block until the human responds. Leave running; re-run if interrupted — +# queued feedback is never lost. Run in the background if your harness +# time-limits foreground commands. +ecc-plan-canvas await .claude/plans/feature.plan.md +``` + +`await` prints JSON when the human acts: + +```json +{ + "status": "feedback", + "items": [ + { "kind": "annotation", "text": "Split this into two phases", + "anchor": { "selector": "h2:nth-of-type(3)", "tag": "h2", "snippet": "Phase 2: Migration" } }, + { "kind": "verdict", "verdict": "request-changes" } + ] +} +``` + +- `kind: "chat"` — freeform message; answer in the canvas, not the terminal. +- `kind: "annotation"` — feedback anchored to an element (`anchor.selector`, + `anchor.snippet` show what they pointed at; `anchor.textRange.text` when + they highlighted a passage). +- `kind: "verdict"` — `approve` means the plan is CONFIRMED: stop polling, + end the session, and start implementing. `request-changes` means revise the + artifact (the canvas live-reloads it) and keep the loop going. + +**3. Respond in the canvas**, then keep listening — one command does both: + +```bash +ecc-plan-canvas await --reply "Split Phase 2 as requested — take a look." +``` + +**4. End** when review concludes: `ecc-plan-canvas end `. + +## Diagrams (Mermaid) + +When part of the plan is a flow, architecture, sequence, state machine, ER +model, or dependency graph, author it as a fenced ` ```mermaid ` block instead +of ASCII art or a wall of prose — the canvas renders it as a themed diagram the +human can point at. Reach for it when a picture reads faster than a paragraph; +skip it for simple lists or tables. + +````markdown +```mermaid +flowchart LR + A[Market resolves] --> B{Watchers?} + B -->|yes| C[Enqueue jobs] --> D[Fan-out worker] +``` +```` + +Diagrams render in the ECC dark theme with the accent palette. Mermaid loads in +the browser from a pinned CDN; if that is unavailable (offline), the block +degrades to showing its source, so the review is never blocked. Point a local +mirror at `ECC_PLAN_CANVAS_MERMAID_URL` for air-gapped use. + +## Rules + +- Markdown artifacts render in ECC's plan template (including Mermaid blocks); + `.html` artifacts render as-is with the annotation layer injected. For HTML + authoring guidance use the `frontend-design-direction` and `artifact-design` + skills. +- Edit the artifact file to revise — the canvas live-reloads on save. Never + re-run `open` to refresh. +- `{"status": "ended", "endedBy": "user"}` (or `sessionEnded: true` on a + feedback batch) means the user closed the review: stop polling, deliver + remaining updates in chat, and do not reopen. A plain `open` on that + session is refused; pass `--reopen` only when the user asks to resume. +- Sibling assets (images, CSS) must sit next to the artifact and be + referenced by relative path. +- The server is loopback-only and exits after 30 idle minutes + (`ECC_PLAN_CANVAS_IDLE_MS`); `stop` shuts it down explicitly. State lives + in `~/.claude/plan-canvas/` (`ECC_PLAN_CANVAS_STATE_DIR`). + +## Examples + +**Plan approval flow** — `/plan` writes +`.claude/plans/notifications.plan.md` and must WAIT for confirmation: + +```bash +ecc-plan-canvas open .claude/plans/notifications.plan.md +ecc-plan-canvas await .claude/plans/notifications.plan.md +# → {"status":"feedback","items":[{"kind":"verdict","verdict":"approve"}]} +ecc-plan-canvas end .claude/plans/notifications.plan.md +# plan is confirmed — begin implementation +``` + +**Revision loop** — feedback arrives, you edit the file, reply, keep listening: + +```bash +# await returned annotations → edit the .plan.md (canvas live-reloads) +ecc-plan-canvas await --reply "Reworked the risk table." +# → blocks again until the next response +``` + +## Anti-Patterns + +- Polling with `--timeout-ms` in a loop — it exists for tests. Leave the + plain `await` running instead. +- Reopening after a user-initiated end "just to show" something. +- Pasting the whole plan into chat *and* opening a canvas — pick the canvas + and keep the terminal summary to one line. +- Parsing the canvas chat from state files — everything you need arrives via + `await`. diff --git a/.agents/skills/plan-canvas/agents/openai.yaml b/.agents/skills/plan-canvas/agents/openai.yaml new file mode 100644 index 000000000..8318d3b53 --- /dev/null +++ b/.agents/skills/plan-canvas/agents/openai.yaml @@ -0,0 +1,7 @@ +interface: + display_name: "Plan Canvas" + short_description: "Browser annotate-and-approve review for plan artifacts" + brand_color: "#6885E8" + default_prompt: "Use $plan-canvas to open a plan in the browser for annotate-and-approve review." +policy: + allow_implicit_invocation: true diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 829d903e2..82392836b 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -11,7 +11,7 @@ { "name": "ecc", "source": "./", - "description": "Harness-native ECC operator layer - 67 agents, 277 skills, 93 legacy command shims, reusable hooks, rules, selective install profiles, and production-ready workflows for Claude Code, Codex, OpenCode, Cursor, and related agent harnesses", + "description": "Harness-native ECC operator layer - 67 agents, 278 skills, 94 legacy command shims, reusable hooks, rules, selective install profiles, and production-ready workflows for Claude Code, Codex, OpenCode, Cursor, and related agent harnesses", "version": "2.0.0", "author": { "name": "Affaan Mustafa", diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 504ecbff4..16e54ca53 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "ecc", "version": "2.0.0", - "description": "Harness-native ECC plugin for engineering teams - 67 agents, 277 skills, 93 legacy command shims, reusable hooks, rules, MCP conventions, and operator workflows for Claude Code plus adjacent agent harnesses", + "description": "Harness-native ECC plugin for engineering teams - 67 agents, 278 skills, 94 legacy command shims, reusable hooks, rules, MCP conventions, and operator workflows for Claude Code plus adjacent agent harnesses", "author": { "name": "Affaan Mustafa", "url": "https://x.com/affaanmustafa" diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index fdade2cda..0501050cb 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -27,6 +27,17 @@ - [ ] No sensitive data exposed in logs or output - [ ] Follows conventional commits format +## If you changed dependencies or `package.json` (`bin` / `files` / deps) +- [ ] Ran `yarn install --mode=update-lockfile` and committed the `yarn.lock` change. CI runs Yarn in hardened mode on public PRs and fails if the lockfile would be modified, so an out of date `yarn.lock` breaks the build even when nothing else is wrong. + +## If you added a skill, command, agent, hook, or CLI tool +- [ ] Registered in `package.json` (`bin` and `files`), `manifests/install-components.json`, `manifests/install-modules.json`, and `agent.yaml` +- [ ] Regenerated the catalog (`npm run catalog:sync`) and command registry (`npm run command-registry:write`) +- [ ] Updated the docs tables it belongs in (`README.md`, `COMMANDS-QUICK-REF.md`, `docs/COMMAND-AGENT-MAP.md`) +- [ ] If it ships a new script path, added it to the publish surface allowlist (`tests/scripts/npm-publish-surface.test.js`) +- [ ] Cross-harness surfaces updated if applicable (for Codex, `.agents/skills//` plus `agents/openai.yaml`; the Codex frontmatter validator allows only `name`, `description`, `metadata`, `license`, `allowed-tools`, so drop keys like `version` from that copy) +- [ ] Full gauntlet passes locally (`npm test`) + ## Documentation - [ ] Updated relevant documentation - [ ] Added comments for complex logic diff --git a/AGENTS.md b/AGENTS.md index 2ffe06a0b..40c355be2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # Everything Claude Code (ECC) — Agent Instructions -This is a **production-ready AI coding plugin** providing 67 specialized agents, 277 skills, 93 commands, and automated hook workflows for software development. +This is a **production-ready AI coding plugin** providing 67 specialized agents, 278 skills, 94 commands, and automated hook workflows for software development. **Version:** 2.0.0 @@ -152,8 +152,8 @@ Troubleshoot failures: check test isolation → verify mocks → fix implementat ``` agents/ — 67 specialized subagents -skills/ — 277 workflow skills and domain knowledge -commands/ — 93 slash commands +skills/ — 278 workflow skills and domain knowledge +commands/ — 94 slash commands hooks/ — Trigger-based automations rules/ — Always-follow guidelines (common + per-language) scripts/ — Cross-platform Node.js utilities diff --git a/COMMANDS-QUICK-REF.md b/COMMANDS-QUICK-REF.md index b1bcab691..fff319049 100644 --- a/COMMANDS-QUICK-REF.md +++ b/COMMANDS-QUICK-REF.md @@ -9,6 +9,7 @@ | Command | What it does | |---------|-------------| | `/plan` | Restate requirements, assess risks, write step-by-step implementation plan — **waits for your confirm before touching code** | +| `/plan-canvas` | Open a plan or HTML artifact in the browser Plan Canvas — annotate elements, chat with the agent, approve or request changes in place | | `/tdd` | Enforce test-driven development: scaffold interface → write failing test → implement → verify 80%+ coverage | | `/code-review` | Full code quality, security, and maintainability review of changed files | | `/build-fix` | Detect and fix build errors — delegates to the right build-resolver agent automatically | diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ec983b4e6..8157a38c8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -464,7 +464,19 @@ How you tested this. - [ ] Clear descriptions ``` -### 3. Review Process +### 3. Before You Push (avoid red CI) + +Run `npm test` locally. It is the same gauntlet CI runs, and it catches almost everything below. + +- **Changed `package.json`?** If you touched `bin`, `files`, or dependencies, run `yarn install --mode=update-lockfile` and commit the `yarn.lock` change. CI runs Yarn in hardened mode on public PRs and fails if the lockfile would be modified, so a stale `yarn.lock` breaks the build on its own. +- **Added a skill, command, agent, hook, or CLI tool?** Wire up every surface it belongs to: + - `package.json` (`bin` and `files`), `manifests/install-components.json`, `manifests/install-modules.json`, and `agent.yaml` + - Regenerate the catalog (`npm run catalog:sync`) and command registry (`npm run command-registry:write`) + - Update the docs tables (`README.md`, `COMMANDS-QUICK-REF.md`, `docs/COMMAND-AGENT-MAP.md`) + - New script path? Add it to the publish surface allowlist (`tests/scripts/npm-publish-surface.test.js`) + - Cross-harness: for Codex, add `.agents/skills//` plus `agents/openai.yaml`. The Codex frontmatter validator only allows `name`, `description`, `metadata`, `license`, and `allowed-tools`, so drop keys like `version` from that copy. + +### 4. Review Process 1. Maintainers review within 48 hours 2. Address feedback if requested diff --git a/README.md b/README.md index bdc9298bf..125692102 100644 --- a/README.md +++ b/README.md @@ -439,7 +439,7 @@ If you stacked methods, clean up in this order: /plugin list ecc@ecc ``` -**That's it!** You now have access to 67 agents, 277 skills, and 93 legacy command shims. +**That's it!** You now have access to 67 agents, 278 skills, and 94 legacy command shims. ### Dashboard GUI @@ -1533,8 +1533,8 @@ The configuration is automatically detected from `.opencode/opencode.json`. | Feature | Claude Code | OpenCode | Status | |---------|---------------------|----------|--------| | Agents | PASS: 67 agents | PASS: 12 agents | **Claude Code leads** | -| Commands | PASS: 93 commands | PASS: 35 commands | **Claude Code leads** | -| Skills | PASS: 277 skills | PASS: 37 skills | **Claude Code leads** | +| Commands | PASS: 94 commands | PASS: 35 commands | **Claude Code leads** | +| Skills | PASS: 278 skills | PASS: 37 skills | **Claude Code leads** | | Hooks | PASS: 8 event types | PASS: 11 events | **OpenCode has more!** | | Rules | PASS: 29 rules | PASS: 13 instructions | **Claude Code leads** | | MCP Servers | PASS: 14 servers | PASS: Full | **Full parity** | @@ -1694,8 +1694,8 @@ ECC is the **first plugin to maximize every major AI coding tool**. Here's how e | Feature | Claude Code | Cursor IDE | Codex CLI | OpenCode | GitHub Copilot | |---------|-----------------------|------------|-----------|----------|----------------| | **Agents** | 67 | Shared (AGENTS.md) | Shared (AGENTS.md) | 12 | N/A | -| **Commands** | 93 | Shared | Instruction-based | 35 | 5 prompts | -| **Skills** | 277 | Shared | 10 (native format) | 37 | Via instructions | +| **Commands** | 94 | Shared | Instruction-based | 35 | 5 prompts | +| **Skills** | 278 | Shared | 10 (native format) | 37 | Via instructions | | **Hook Events** | 8 types | 15 types | None yet | 11 types | None | | **Hook Scripts** | 20+ scripts | 16 scripts (DRY adapter) | N/A | Plugin hooks | N/A | | **Rules** | 34 (common + lang) | 34 (YAML frontmatter) | Instruction-based | 13 instructions | 1 always-on file | diff --git a/README.zh-CN.md b/README.zh-CN.md index 9298011e2..1f0a94c22 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -164,7 +164,7 @@ Copy-Item -Recurse rules/typescript "$HOME/.claude/rules/" /plugin list ecc@ecc ``` -**完成!** 你现在可以使用 67 个代理、277 个技能和 93 个命令。 +**完成!** 你现在可以使用 67 个代理、278 个技能和 94 个命令。 ### multi-* 命令需要额外配置 diff --git a/agent.yaml b/agent.yaml index a3c437884..6275ce2ee 100644 --- a/agent.yaml +++ b/agent.yaml @@ -108,6 +108,7 @@ skills: - perl-security - perl-testing - plankton-code-quality + - plan-canvas - plan-orchestrate - postgres-patterns - product-lens @@ -215,6 +216,7 @@ commands: - orch-refine-code - orch-review - plan + - plan-canvas - plan-prd - pm2 - projects diff --git a/commands/plan-canvas.md b/commands/plan-canvas.md new file mode 100644 index 000000000..8fd4c63c0 --- /dev/null +++ b/commands/plan-canvas.md @@ -0,0 +1,45 @@ +--- +description: Open a plan or HTML artifact in the browser Plan Canvas for annotate-and-approve review +argument-hint: "[path/to/artifact.plan.md | path/to/artifact.html]" +--- + +# Plan Canvas Command + +Opens a local artifact in the Plan Canvas — ECC's browser review surface — +where the user annotates elements, chats with you, and approves the plan or +requests changes without leaving the page. + +This command is a thin entry point over the `plan-canvas` skill. Follow that +skill for the full workflow and rules. + +## What This Command Does + +1. Resolve the artifact: the given path, else the most recently modified + `.claude/plans/*.plan.md`, else ask what to review. +2. `ecc-plan-canvas open ` — opens the user's browser. +3. `ecc-plan-canvas await ` — block until feedback, + verdict, or session end; leave it running. +4. Apply feedback to the artifact file (the canvas live-reloads), answer with + `await --reply "..."`, and repeat until the user approves or + ends the session. + +An `approve` verdict counts as plan confirmation for `/plan`-style gates: +stop polling, `end` the session, and begin implementation. + +## Example + +``` +User: /plan-canvas .claude/plans/notifications.plan.md + +Assistant: (runs open + await, browser opens) +...user clicks "Request changes" with two annotations... +Assistant: (edits the plan, replies in-canvas, awaits again) +...user clicks "Approve plan"... +Assistant: Plan approved in the canvas — starting implementation. +``` + +## Related + +- `plan-canvas` skill — full workflow, feedback JSON shapes, rules +- `/plan` — produces the plan artifacts this reviews +- Source: `scripts/plan-canvas.js`, `scripts/lib/plan-canvas/` diff --git a/commands/plan.md b/commands/plan.md index aed475034..739752957 100644 --- a/commands/plan.md +++ b/commands/plan.md @@ -111,6 +111,11 @@ When called with a `.prd.md` file, write the plan to `.claude/plans/{kebab-case- After writing the artifact, report its path and WAIT for confirmation before writing code. +> **Visual review:** instead of asking for a typed confirmation, you can open the +> artifact in the browser Plan Canvas (`/plan-canvas`, or the `plan-canvas` skill): +> the user annotates the plan in place and clicks **Approve plan** or **Request +> changes**, which arrives as your confirmation signal. + ## Example Usage ``` @@ -181,6 +186,7 @@ If you want changes, respond with: ## Integration with Other Commands After planning: +- Use `/plan-canvas` to run the confirmation gate visually in the browser (annotate + approve) - Use the `tdd-workflow` skill to implement with test-driven development - Use `/build-fix` if build errors occur - Use `/code-review` to review completed implementation diff --git a/docs/COMMAND-AGENT-MAP.md b/docs/COMMAND-AGENT-MAP.md index 70bcacfc1..d99ee12cc 100644 --- a/docs/COMMAND-AGENT-MAP.md +++ b/docs/COMMAND-AGENT-MAP.md @@ -5,6 +5,7 @@ This document lists each slash command and the primary agent(s) or skills it inv | Command | Primary agent(s) | Notes | |---------|------------------|--------| | `/plan` | planner | Implementation planning before code | +| `/plan-canvas` | — (skill: plan-canvas) | Browser review canvas for plan artifacts: annotate, chat, approve/request changes | | `/tdd` | tdd-guide | Test-driven development | | `/code-review` | code-reviewer | Quality and security review | | `/build-fix` | build-error-resolver | Fix build/type errors | diff --git a/docs/COMMAND-REGISTRY.json b/docs/COMMAND-REGISTRY.json index 0e1444e68..29b1cd647 100644 --- a/docs/COMMAND-REGISTRY.json +++ b/docs/COMMAND-REGISTRY.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "totalCommands": 93, + "totalCommands": 94, "commands": [ { "command": "aside", @@ -626,6 +626,17 @@ "skills": [], "path": "commands/orch-review.md" }, + { + "command": "plan-canvas", + "description": "Open a plan or HTML artifact in the browser Plan Canvas for annotate-and-approve review", + "type": "review", + "primaryAgents": [], + "allAgents": [], + "skills": [ + "plan-canvas" + ], + "path": "commands/plan-canvas.md" + }, { "command": "plan-prd", "description": "Generate a lean, problem-first PRD and hand off to /plan for implementation planning.", @@ -645,7 +656,9 @@ "allAgents": [ "planner" ], - "skills": [], + "skills": [ + "plan-canvas" + ], "path": "commands/plan.md" }, { @@ -1020,7 +1033,7 @@ "orchestration": 11, "planning": 2, "refactoring": 1, - "review": 14, + "review": 15, "testing": 53 }, "topAgents": [ diff --git a/docs/design/assets/plan-canvas-demo.png b/docs/design/assets/plan-canvas-demo.png new file mode 100644 index 000000000..39d8fde1f Binary files /dev/null and b/docs/design/assets/plan-canvas-demo.png differ diff --git a/docs/design/plan-canvas.md b/docs/design/plan-canvas.md new file mode 100644 index 000000000..3de3fc733 --- /dev/null +++ b/docs/design/plan-canvas.md @@ -0,0 +1,120 @@ +# Plan Canvas — interactive plan review in the browser + +Status: implemented (`feat/plan-canvas`) +Inspired by: [lavish-axi](https://github.com/kunchenguid/lavish-axi) by @kunchenguid, the +idea of a local, annotate-and-chat review loop over agent-generated artifacts. Plan Canvas is +an original, ECC-native implementation of that idea, not a port. + +![Plan Canvas reviewing a plan on the left while the agent works in the terminal on the right](assets/plan-canvas-demo.png) + +## Problem + +`/plan` ends with a hard gate: the agent writes `.claude/plans/{name}.plan.md` and WAITS for +the user to confirm. Today that review happens as a wall of markdown in the terminal, and the +feedback loop is "retype what you want changed in chat." The community has asked for the same +loop lavish-axi popularized: see the plan rendered properly, point at the part you mean, and +talk to the agent from the page. + +## What it is + +A loopback-only web editor for plan artifacts (and any local HTML artifact): + +- The agent runs `node scripts/plan-canvas.js open ` after writing a plan. +- The artifact opens in the browser inside ECC-styled chrome (same design tokens as + `scripts/dashboard-web.js`): dark-first, `--accent #6885e8`, accent→pink brand gradient, + light theme toggle. +- The human reviews visually, clicks elements or selects text to attach numbered annotations, + and chats with the agent from a side rail. +- Plan-specific verdict actions — **Approve plan** / **Request changes** — map directly onto + `/plan`'s CONFIRM gate, so approval can happen from the canvas instead of the terminal. +- The agent blocks on `node scripts/plan-canvas.js await ` (long poll). Feedback + arrives as JSON on stdout: chat messages, annotations with CSS-selector + text-range + anchors, verdicts, or session-end. +- The agent replies with `await --reply "..."`, which appears in the canvas chat; edits to the + artifact file live-reload the page. + +## How it fits ECC + +| Piece | Location | Follows | +|---|---|---| +| CLI entry | `scripts/plan-canvas.js` (+ npm bin `ecc-plan-canvas`) | `scripts/control-pane.js` | +| Server | `scripts/lib/plan-canvas/server.js` | control-pane loopback server, host-header + Origin allowlist (DNS-rebinding guard) | +| Editor chrome | `scripts/lib/plan-canvas/ui.js` | `scripts/lib/control-pane/ui.js`, tokens from `scripts/dashboard-web.js` | +| Markdown plan renderer | `scripts/lib/plan-canvas/markdown.js` | zero new deps; renders the `commands/plan.md` artifact schema (tables, tasks, code fences, Mermaid blocks) | +| Mermaid diagrams | `scripts/lib/plan-canvas/ui.js` | ` ```mermaid ` blocks render in the browser, themed to ECC; pinned CDN with offline fallback (`ECC_PLAN_CANVAS_MERMAID_URL` for a local mirror) | +| Session state | `scripts/lib/plan-canvas/sessions.js` | file-path-keyed sessions, state under `~/.claude/plan-canvas/` (`ECC_PLAN_CANVAS_STATE_DIR` override) | +| Skill | `skills/plan-canvas/SKILL.md` | skills-first surface; teaches the open → await → reply loop; defers visual guidance to `frontend-design-direction`, `artifact-design`, `dataviz` | +| Command shim | `commands/plan-canvas.md` | legacy parity surface, points at the skill | +| `/plan` pointer | `commands/plan.md` | after writing the artifact, offer canvas review | +| Hook (optional) | `scripts/hooks/plan-canvas-sessions.js`, `SessionStart` | surfaces open canvas sessions so a fresh session can resume a review | +| Tests | `tests/lib/plan-canvas/*`, `tests/integration/plan-canvas-e2e.test.js` | node:test-style plain assert, run by `tests/run-all.js` | + +Registration: `package.json` (`bin`, `files[]`), `manifests/install-components.json` +(+ `install-modules.json` workflow-quality paths), `agent.yaml` skills list, catalog + +command-registry regeneration. + +## Cross-harness / model compatibility + +The feature is model- and harness-agnostic by construction: the CLI emits plain JSON and the +skill teaches a shell-plus-stdout loop, so any capable agent drives it identically — the same +"just a CLI" thesis lavish-axi uses. There is no Claude-only dependency in the core loop; the +`SessionStart` hook is an additive Claude Code convenience (other harnesses see open sessions +from a bare `ecc-plan-canvas` invocation). + +Surfaces mirror how peer workflow-quality skills ship across ECC's harnesses: + +- `skills/plan-canvas/` — canonical (Claude Code and the installer's per-target adapters). +- `.agents/skills/plan-canvas/` (+ `agents/openai.yaml` interface manifest) — Codex, alongside + `tdd-workflow`, `e2e-testing`, `verification-loop`. +- `agent.yaml` skills list — the Codex gitagent manifest. +- The CLI resolves from any project via the `ecc-plan-canvas` bin (global/plugin install) or + `$CLAUDE_PLUGIN_ROOT/scripts/plan-canvas.js`, never a cwd-relative path. + +Cursor's checked-in subset is content/marketing skills only, so — matching peers — plan-canvas +is not added there; the installer still places it for Cursor from the canonical `skills/`. + +## Protocol + +Sessions are keyed by canonical artifact path (`sha256(realpath)[:12]`). The CLI talks to a +detached server (`server.json` in the state dir records pid/port/version; idle self-shutdown +after 30 min, `ECC_PLAN_CANVAS_IDLE_MS`). Feedback is deliver-and-drain: queued items are +handed to exactly one `await` call and persisted to disk until then, so nothing is lost if +the poll is interrupted. + +- `GET /health` — `{ok, app: "ecc-plan-canvas", version}` (CLI/server version handshake) +- `GET /` — session list (ECC chrome) +- `POST /api/sessions` `{file, reopen?}` — open/resume; `409 user-ended` unless `reopen` +- `GET /canvas/` — editor chrome; `GET /artifact//` — rendered artifact + (markdown → ECC plan template, HTML passthrough) with the annotation SDK injected; + sibling assets confined to the artifact directory +- `POST /api/session//feedback` `{items[], endSession?}` — browser queues + chat / annotation / verdict items +- `GET /api/await?file=[&timeoutMs=n]` — agent long-poll (whitespace heartbeat); + returns `{status: feedback|ended|waiting|missing, items[], sessionEnded?, endedBy?}` +- `POST /api/session//reply` `{text}` — agent message → canvas chat +- `POST /api/session//end` (user) / `POST /api/end` `{file}` (agent) — ender recorded; + user ends are sticky: plain `open` refuses to reopen without `--reopen` +- `GET /events/` — SSE to the browser: `chat-sync`, `presence` + (waiting/listening/working), `reload` (artifact file changed), `ended` + +## Deliberate differences from lavish-axi + +- Plan-first: renders `.plan.md` / `.md` natively (including Mermaid); lavish is HTML-only. +- Verdict actions wired to ECC's plan-confirmation workflow. +- ECC design tokens and chrome; JSON (not TOON) agent output. +- Mermaid renders themed to ECC, but without lavish's pan/zoom or node-id capture — + whole-element annotation covers pointing at a diagram or node. +- No export/share hosting, no layout-audit gate, no bundled playbooks — ECC's existing + design skills (`frontend-design-direction`, `artifact-design`, `dataviz`) cover authoring. + +## Security posture + +Loopback bind only by default; Host and Origin allowlist checks on every request (same +approach as control-pane); artifact served only from registered session paths with +sibling-asset access confined to the artifact directory; state dir is user-local. The server +never executes artifact content — it only serves it to the browser. + +The one optional outbound request is the pinned Mermaid library, fetched by the browser only +for artifacts that contain a diagram; it renders with `securityLevel: 'strict'`, degrades to +showing diagram source if unavailable, and can be repointed at a local mirror via +`ECC_PLAN_CANVAS_MERMAID_URL`. The server itself still makes no network calls. diff --git a/docs/zh-CN/AGENTS.md b/docs/zh-CN/AGENTS.md index 72586c0e2..6326e8b6f 100644 --- a/docs/zh-CN/AGENTS.md +++ b/docs/zh-CN/AGENTS.md @@ -1,6 +1,6 @@ # Everything Claude Code (ECC) — 智能体指令 -这是一个**生产就绪的 AI 编码插件**,提供 67 个专业代理、277 项技能、93 条命令以及自动化钩子工作流,用于软件开发。 +这是一个**生产就绪的 AI 编码插件**,提供 67 个专业代理、278 项技能、94 条命令以及自动化钩子工作流,用于软件开发。 **版本:** 2.0.0 @@ -147,8 +147,8 @@ ``` agents/ — 67 个专业子代理 -skills/ — 277 个工作流技能和领域知识 -commands/ — 93 个斜杠命令 +skills/ — 278 个工作流技能和领域知识 +commands/ — 94 个斜杠命令 hooks/ — 基于触发的自动化 rules/ — 始终遵循的指导方针(通用 + 每种语言) scripts/ — 跨平台 Node.js 实用工具 diff --git a/docs/zh-CN/README.md b/docs/zh-CN/README.md index f16e8a887..1e6546b72 100644 --- a/docs/zh-CN/README.md +++ b/docs/zh-CN/README.md @@ -228,7 +228,7 @@ Copy-Item -Recurse rules/typescript "$HOME/.claude/rules/" /plugin list ecc@ecc ``` -**搞定!** 你现在可以使用 67 个智能体、277 项技能和 93 个命令了。 +**搞定!** 你现在可以使用 67 个智能体、278 项技能和 94 个命令了。 *** @@ -1141,8 +1141,8 @@ opencode | 功能特性 | Claude Code | OpenCode | 状态 | |---------|---------------|----------|--------| | 智能体 | PASS: 67 个 | PASS: 12 个 | **Claude Code 领先** | -| 命令 | PASS: 93 个 | PASS: 35 个 | **Claude Code 领先** | -| 技能 | PASS: 277 项 | PASS: 37 项 | **Claude Code 领先** | +| 命令 | PASS: 94 个 | PASS: 35 个 | **Claude Code 领先** | +| 技能 | PASS: 278 项 | PASS: 37 项 | **Claude Code 领先** | | 钩子 | PASS: 8 种事件类型 | PASS: 11 种事件 | **OpenCode 更多!** | | 规则 | PASS: 29 条 | PASS: 13 条指令 | **Claude Code 领先** | | MCP 服务器 | PASS: 14 个 | PASS: 完整 | **完全对等** | @@ -1249,8 +1249,8 @@ ECC 是**第一个最大化利用每个主要 AI 编码工具的插件**。以 | 功能特性 | Claude Code | Cursor IDE | Codex CLI | OpenCode | |---------|-----------------------|------------|-----------|----------| | **智能体** | 67 | 共享 (AGENTS.md) | 共享 (AGENTS.md) | 12 | -| **命令** | 93 | 共享 | 基于指令 | 35 | -| **技能** | 277 | 共享 | 10 (原生格式) | 37 | +| **命令** | 94 | 共享 | 基于指令 | 35 | +| **技能** | 278 | 共享 | 10 (原生格式) | 37 | | **钩子事件** | 8 种类型 | 15 种类型 | 暂无 | 11 种类型 | | **钩子脚本** | 20+ 个脚本 | 16 个脚本 (DRY 适配器) | N/A | 插件钩子 | | **规则** | 34 (通用 + 语言) | 34 (YAML 前页) | 基于指令 | 13 条指令 | diff --git a/hooks/README.md b/hooks/README.md index 8df6e4f93..c9a9107fc 100644 --- a/hooks/README.md +++ b/hooks/README.md @@ -63,6 +63,7 @@ That installs resolved hooks to `~/.claude/hooks/hooks.json`. On Windows, the Cl | Hook | Event | What It Does | |------|-------|-------------| | **Session start** | `SessionStart` | Loads previous context and detects package manager | +| **Plan Canvas sessions** | `SessionStart` | Surfaces open Plan Canvas browser reviews so a fresh session can resume the loop | | **Pre-compact** | `PreCompact` | Saves state before context compaction | | **Console.log audit** | `Stop` | Checks all modified files for `console.log` after each response | | **Session summary** | `Stop` | Persists session state when transcript path is available | diff --git a/hooks/hooks.json b/hooks/hooks.json index 7bfdefe1f..8367f833f 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -120,6 +120,17 @@ ], "description": "Load previous context and detect package manager on new session", "id": "session:start" + }, + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "node -e \"const p=require('path');const r=(function(){var p=require('path'),f=require('fs'),o=require('os');var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var d=p.join(o.homedir(),'.claude');function L(x){try{return require(p.join(x,'scripts','lib','resolve-ecc-root')).resolveEccRoot()}catch(_){return null}}var r=L(d);if(r)return r;var s=['ecc','ecc@ecc','marketplaces/ecc','everything-claude-code','everything-claude-code@everything-claude-code','marketplaces/everything-claude-code'];for(var i=0;i` instead of leaving the human talking to an + * empty chair in the browser. + * + * Never blocks: exits 0 on every error, prints nothing when there is + * nothing to resume. + */ + +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const os = require('os'); + +function stateDir() { + const override = process.env.ECC_PLAN_CANVAS_STATE_DIR; + if (override && override.trim()) return path.resolve(override.trim()); + return path.join(os.homedir(), '.claude', 'plan-canvas'); +} + +function openSessions() { + try { + const parsed = JSON.parse(fs.readFileSync(path.join(stateDir(), 'sessions.json'), 'utf8')); + return Object.values(parsed.sessions || {}).filter(session => session.status !== 'ended'); + } catch { + return []; + } +} + +function buildContext(sessions) { + const lines = [ + '[PlanCanvas] Open browser review sessions from a previous run:' + ]; + for (const session of sessions.slice(0, 5)) { + const pending = session.pendingFeedback && session.pendingFeedback.length; + lines.push(` - ${session.file}${pending ? ` (${pending} undelivered feedback item${pending === 1 ? '' : 's'})` : ''}`); + } + lines.push( + 'Resume with `node scripts/plan-canvas.js await ` (plan-canvas skill), or `end ` if the review is obsolete.' + ); + return lines.join('\n'); +} + +function run() { + const sessions = openSessions(); + if (sessions.length > 0) { + process.stdout.write(`${buildContext(sessions)}\n`); + } + return 0; +} + +if (require.main === module) { + try { + process.exit(run()); + } catch (error) { + process.stderr.write(`[PlanCanvas] WARNING: ${error.message}\n`); + process.exit(0); + } +} + +module.exports = { run, openSessions, buildContext }; diff --git a/scripts/lib/control-pane/server.js b/scripts/lib/control-pane/server.js index 81f8a99ed..bfe847159 100644 --- a/scripts/lib/control-pane/server.js +++ b/scripts/lib/control-pane/server.js @@ -24,42 +24,14 @@ async function withStateStore(stateDbPath, fn) { } } -const LOOPBACK_HOSTNAMES = new Set(['127.0.0.1', 'localhost', '[::1]', '::1']); - -// Extract the hostname portion of an HTTP Host header value, stripping any -// port. Returns null when the header is missing or malformed. Used to gate -// requests against a local-only allowlist so DNS-rebinding cannot pivot a -// browser tab into the loopback control-pane API. -function parseHostHeader(value) { - if (!value || typeof value !== 'string') return null; - const trimmed = value.trim(); - if (!trimmed) return null; - const match = trimmed.match(/^(\[[^\]]+\]|[^:]+)(?::\d+)?$/); - if (!match) return null; - return match[1].toLowerCase(); -} - -function buildAllowedHostnames(configuredHost) { - const set = new Set(LOOPBACK_HOSTNAMES); - if (configuredHost) set.add(String(configuredHost).toLowerCase()); - return set; -} - -function isAllowedHostHeader(hostHeader, allowedHostnames) { - const hostname = parseHostHeader(hostHeader); - if (!hostname) return false; - return allowedHostnames.has(hostname); -} - -function isAllowedOrigin(originHeader, allowedHostnames) { - if (!originHeader || typeof originHeader !== 'string') return true; - try { - const url = new URL(originHeader); - return allowedHostnames.has(url.hostname.toLowerCase()); - } catch { - return false; - } -} +// Host/Origin gating lives in scripts/lib/loopback-guard.js so every ECC +// loopback server shares one hardened implementation; re-exported below to +// keep this module's public API stable. +const { + buildAllowedHostnames, + isAllowedHostHeader, + isAllowedOrigin +} = require('../loopback-guard'); function usage() { return [ diff --git a/scripts/lib/loopback-guard.js b/scripts/lib/loopback-guard.js new file mode 100644 index 000000000..cde3373a9 --- /dev/null +++ b/scripts/lib/loopback-guard.js @@ -0,0 +1,53 @@ +'use strict'; + +/** + * Host/Origin gating for ECC's loopback HTTP servers (control pane, plan + * canvas). DNS rebinding can point an attacker-controlled hostname at + * 127.0.0.1, so every request must present a Host header from this + * allowlist before the server does any work. + */ + +const LOOPBACK_HOSTNAMES = new Set(['127.0.0.1', 'localhost', '[::1]', '::1']); + +// Extract the hostname portion of an HTTP Host header value, stripping any +// port. Returns null when the header is missing or malformed. +function parseHostHeader(value) { + if (!value || typeof value !== 'string') return null; + const trimmed = value.trim(); + if (!trimmed) return null; + const match = trimmed.match(/^(\[[^\]]+\]|[^:]+)(?::\d+)?$/); + if (!match) return null; + return match[1].toLowerCase(); +} + +function buildAllowedHostnames(configuredHost) { + const set = new Set(LOOPBACK_HOSTNAMES); + if (configuredHost) set.add(String(configuredHost).toLowerCase()); + return set; +} + +function isAllowedHostHeader(hostHeader, allowedHostnames) { + const hostname = parseHostHeader(hostHeader); + if (!hostname) return false; + return allowedHostnames.has(hostname); +} + +// Origin is absent on same-origin navigations and CLI clients; when present +// it must resolve to an allowed hostname. +function isAllowedOrigin(originHeader, allowedHostnames) { + if (!originHeader || typeof originHeader !== 'string') return true; + try { + const url = new URL(originHeader); + return allowedHostnames.has(url.hostname.toLowerCase()); + } catch { + return false; + } +} + +module.exports = { + LOOPBACK_HOSTNAMES, + buildAllowedHostnames, + isAllowedHostHeader, + isAllowedOrigin, + parseHostHeader +}; diff --git a/scripts/lib/plan-canvas/markdown.js b/scripts/lib/plan-canvas/markdown.js new file mode 100644 index 000000000..84799a3c4 --- /dev/null +++ b/scripts/lib/plan-canvas/markdown.js @@ -0,0 +1,277 @@ +'use strict'; + +/** + * Minimal GitHub-flavored-markdown subset renderer for Plan Canvas. + * Renders .claude/plans/*.plan.md artifacts to HTML body content. + * + * Security model: the entire source line is HTML-escaped before any inline + * rule runs, so raw HTML in the markdown always displays as text. Link and + * image URLs are validated against an allowlist of protocols. + */ + +// Placeholders live in the Unicode private-use area so escaped output can +// never collide with them. Pre-existing occurrences are stripped from input. +const TOKEN_OPEN = '\uE000'; +const TOKEN_CLOSE = '\uE001'; +const TOKEN_RE = new RegExp(TOKEN_OPEN + '(\\d+)' + TOKEN_CLOSE, 'g'); +const STRIP_RE = new RegExp('[' + TOKEN_OPEN + TOKEN_CLOSE + ']', 'g'); + +const LIST_ITEM_RE = /^(\s*)([-*]|\d+\.)\s+(.*)$/; +const HR_RE = /^ {0,3}(-{3,}|\*{3,})\s*$/; + +function escapeHtml(value) { + return String(value ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function slugify(text) { + return String(text ?? '') + .toLowerCase() + .replace(/[^a-z0-9\s-]/g, '') + .trim() + .replace(/[\s-]+/g, '-') + .replace(/^-+|-+$/g, ''); +} + +// Strip whitespace/control characters so "Ja vaScript:" style tricks cannot +// hide a scheme, then classify against the allowlist. +function classifyUrl(rawUrl) { + const compact = String(rawUrl) + .split('') + .filter((ch) => ch.charCodeAt(0) > 32) + .join('') + .toLowerCase(); + if (compact.startsWith('#')) return 'anchor'; + if (compact.startsWith('//')) return 'blocked'; + const scheme = compact.match(/^[a-z][a-z0-9+.-]*:/); + if (!scheme) return 'relative'; + if (scheme[0] === 'http:' || scheme[0] === 'https:') return 'http'; + if (scheme[0] === 'mailto:') return 'mailto'; + return 'blocked'; +} + +function applyEmphasis(s) { + return s + .replace(/\*\*([^*]+)\*\*/g, '$1') + .replace(/~~([^~]+)~~/g, '$1') + .replace(/\*([^*]+)\*/g, '$1') + .replace(/(^|[^\w])_([^_]+)_(?!\w)/g, '$1$2'); +} + +function renderInline(rawText) { + const tokens = []; + const stash = (html) => { + tokens.push(html); + return TOKEN_OPEN + (tokens.length - 1) + TOKEN_CLOSE; + }; + + let s = escapeHtml(rawText); + + // Code spans first: contents stay escaped and opt out of all other rules. + s = s.replace(/`([^`]+)`/g, (_m, code) => stash('' + code + '')); + + s = s.replace(/!\[([^\]]*)\]\(([^)]*)\)/g, (_m, alt, src) => { + const kind = classifyUrl(src); + if (kind !== 'http' && kind !== 'relative') return alt; + return stash('' + alt + ''); + }); + + s = s.replace(/\[([^\]]+)\]\(([^)]*)\)/g, (_m, label, url) => { + const kind = classifyUrl(url); + const text = applyEmphasis(label); + if (kind === 'blocked') return text; + const extra = kind === 'http' ? ' target="_blank" rel="noopener"' : ''; + return stash('' + text + ''); + }); + + s = applyEmphasis(s); + + // Stashed anchors may hold code-span tokens, so resolve until none remain. + while (s.includes(TOKEN_OPEN)) { + s = s.replace(TOKEN_RE, (_m, idx) => tokens[Number(idx)]); + } + return s; +} + +function splitTableRow(line) { + let s = line.trim(); + if (s.startsWith('|')) s = s.slice(1); + if (s.endsWith('|') && !s.endsWith('\\|')) s = s.slice(0, -1); + return s + .replace(/\\\|/g, TOKEN_OPEN) + .split('|') + .map((cell) => cell.split(TOKEN_OPEN).join('|').trim()); +} + +function isAlignmentRow(line) { + if (!line || !line.includes('|')) return false; + const cells = splitTableRow(line); + return cells.length > 0 && cells.every((cell) => /^:?-+:?$/.test(cell)); +} + +function cellAlign(spec) { + const left = spec.startsWith(':'); + const right = spec.endsWith(':'); + if (left && right) return 'center'; + if (right) return 'right'; + if (left) return 'left'; + return ''; +} + +function renderListItem(text) { + const task = text.match(/^\[([ xX])\]\s+(.*)$/); + if (task) { + const checked = task[1].trim() ? ' checked' : ''; + return '
  • ' + + renderInline(task[2]) + '
  • '; + } + return '
  • ' + renderInline(text) + '
  • '; +} + +function buildList(items, start, indent) { + const tag = /^\d/.test(items[start].marker) ? 'ol' : 'ul'; + const parts = []; + let i = start; + while (i < items.length && items[i].indent >= indent) { + if (items[i].indent > indent) { + // Deeper item: nest a sublist inside the previous
  • + const nested = buildList(items, i, items[i].indent); + if (parts.length > 0) { + const last = parts.pop(); + parts.push(last.replace(/<\/li>$/, '\n' + nested.html + '\n
  • ')); + } else { + parts.push('
  • \n' + nested.html + '\n
  • '); + } + i = nested.end; + } else { + parts.push(renderListItem(items[i].text)); + i += 1; + } + } + return { html: '<' + tag + '>\n' + parts.join('\n') + '\n', end: i }; +} + +function startsBlock(line, nextLine) { + return /^```/.test(line) || + /^#{1,6}\s/.test(line) || + HR_RE.test(line) || + /^ {0,3}>/.test(line) || + LIST_ITEM_RE.test(line) || + (line.includes('|') && isAlignmentRow(nextLine || '')); +} + +function renderMarkdown(text) { + if (!text) return ''; + const lines = String(text) + .replace(STRIP_RE, '') + .replace(/\r\n?/g, '\n') + .split('\n'); + const out = []; + let i = 0; + + while (i < lines.length) { + const line = lines[i]; + + if (!line.trim()) { + i += 1; + continue; + } + + const fence = line.match(/^```(.*)$/); + if (fence) { + const lang = fence[1].trim().split(/\s+/)[0].toLowerCase().replace(/[^a-z0-9-]/g, ''); + const body = []; + i += 1; + while (i < lines.length && !/^```\s*$/.test(lines[i])) { + body.push(lines[i]); + i += 1; + } + i += 1; // skip closing fence (or run off EOF) + if (lang === 'mermaid') { + // Mermaid reads the element's textContent, and the browser decodes + // character references there — so escaping keeps `-->`/`<` intact for + // the renderer while preventing HTML injection or a breakout. + out.push('
    ' + escapeHtml(body.join('\n')) + '
    '); + continue; + } + const cls = lang ? ' class="language-' + lang + '"' : ''; + out.push('
    ' + escapeHtml(body.join('\n')) + '
    '); + continue; + } + + const heading = line.match(/^(#{1,6})\s+(.+?)\s*$/); + if (heading) { + const level = heading[1].length; + out.push('' + + renderInline(heading[2]) + ''); + i += 1; + continue; + } + + // Horizontal rule (alignment rows never reach here: tables consume them) + if (HR_RE.test(line)) { + out.push('
    '); + i += 1; + continue; + } + + // Blockquote: strip one `>` level and recurse, which handles nesting + if (/^ {0,3}>/.test(line)) { + const inner = []; + while (i < lines.length && /^ {0,3}>/.test(lines[i])) { + inner.push(lines[i].replace(/^ {0,3}> ?/, '')); + i += 1; + } + out.push('
    \n' + renderMarkdown(inner.join('\n')) + '\n
    '); + continue; + } + + // Table: header row followed by an alignment row + if (line.includes('|') && isAlignmentRow(lines[i + 1] || '')) { + const aligns = splitTableRow(lines[i + 1]).map(cellAlign); + const row = (tag, cells) => '' + cells.map((cell, idx) => { + const style = aligns[idx] ? ' style="text-align:' + aligns[idx] + '"' : ''; + return '<' + tag + style + '>' + renderInline(cell) + ''; + }).join('') + ''; + const head = row('th', splitTableRow(line)); + const body = []; + i += 2; + while (i < lines.length && lines[i].trim() && lines[i].includes('|')) { + body.push(row('td', splitTableRow(lines[i]))); + i += 1; + } + out.push('\n\n' + head + '\n\n\n' + + body.join('\n') + '\n\n
    '); + continue; + } + + if (LIST_ITEM_RE.test(line)) { + const items = []; + while (i < lines.length) { + const m = lines[i].match(LIST_ITEM_RE); + if (!m) break; + items.push({ indent: m[1].length, marker: m[2], text: m[3] }); + i += 1; + } + out.push(buildList(items, 0, items[0].indent).html); + continue; + } + + // Paragraph: run of plain lines up to a blank line or block start + const para = [line.trim()]; + i += 1; + while (i < lines.length && lines[i].trim() && !startsBlock(lines[i], lines[i + 1])) { + para.push(lines[i].trim()); + i += 1; + } + out.push('

    ' + renderInline(para.join('\n')) + '

    '); + } + + return out.join('\n'); +} + +module.exports = { renderMarkdown, escapeHtml, slugify }; diff --git a/scripts/lib/plan-canvas/sdk.js b/scripts/lib/plan-canvas/sdk.js new file mode 100644 index 000000000..a5110914d --- /dev/null +++ b/scripts/lib/plan-canvas/sdk.js @@ -0,0 +1,237 @@ +'use strict'; + +/** + * Plan Canvas artifact SDK — the script injected into the reviewed artifact. + * + * The artifact runs in a sandboxed iframe without allow-same-origin, so this + * script can only talk to the chrome via postMessage. It renders all of its + * own UI inside a shadow root so it never annotates itself and never leaks + * styles into the artifact. + */ + +function artifactSdkJs() { + return `'use strict'; +(() => { + if (window.parent === window) return; // only meaningful inside the canvas + if (window.__eccPlanCanvasSdk) return; + window.__eccPlanCanvasSdk = true; + + let annotate = true; + let card = null; + + const post = msg => window.parent.postMessage(msg, '*'); + + // --- shadow-root UI host -------------------------------------------- + const host = document.createElement('div'); + host.setAttribute('data-ecc-plan-canvas', 'ui'); + host.style.cssText = 'position:absolute;top:0;left:0;width:0;height:0;z-index:2147483647'; + const root = host.attachShadow({ mode: 'open' }); + root.innerHTML = \` + +
    + +
    +

    +
    + +
    + + +
    +
    Enter to queue · Cmd/Ctrl+Enter to queue & send
    +
    \`; + const attach = () => document.body ? document.body.appendChild(host) : null; + if (document.body) attach(); + else document.addEventListener('DOMContentLoaded', attach); + + const hl = root.querySelector('.hl'); + const selhint = root.querySelector('.selhint'); + const cardEl = root.querySelector('.card'); + const cardTitle = cardEl.querySelector('h4'); + const cardSnippet = cardEl.querySelector('.snippet'); + const cardText = cardEl.querySelector('textarea'); + + // --- selectors & context --------------------------------------------- + const esc = v => (window.CSS && CSS.escape) ? CSS.escape(v) : v.replace(/[^a-zA-Z0-9_-]/g, '\\\\$&'); + function selectorFor(el) { + const parts = []; + let node = el; + for (let depth = 0; node && node.nodeType === 1 && depth < 6; depth++) { + if (node.id) { parts.unshift('#' + esc(node.id)); return parts.join(' > '); } + const tag = node.tagName.toLowerCase(); + if (tag === 'body' || tag === 'html') { parts.unshift(tag); break; } + let nth = 1; + let sib = node; + while ((sib = sib.previousElementSibling)) if (sib.tagName === node.tagName) nth++; + parts.unshift(tag + ':nth-of-type(' + nth + ')'); + node = node.parentElement; + } + return parts.join(' > '); + } + function snippetFor(el) { + return (el.innerText || el.textContent || '').replace(/\\s+/g, ' ').trim().slice(0, 200); + } + const INTERACTIVE = new Set(['button', 'input', 'select', 'textarea', 'option', 'label', 'summary', 'a']); + function isInteractive(el) { + let node = el; + while (node && node.nodeType === 1) { + if (INTERACTIVE.has(node.tagName.toLowerCase()) || node.isContentEditable) return true; + node = node.parentElement; + } + return false; + } + const isOurs = el => el === host || host.contains(el); + + // --- annotation card --------------------------------------------------- + function openCard(target) { + card = target; + cardTitle.textContent = target.kindLabel; + cardSnippet.textContent = target.anchor.snippet || target.anchor.selector; + cardText.value = ''; + cardEl.style.display = 'block'; + const x = Math.min(target.x, window.innerWidth - 320) + window.scrollX; + const y = target.y + 12 + window.scrollY; + cardEl.style.left = Math.max(8, x) + 'px'; + cardEl.style.top = y + 'px'; + cardText.focus(); + } + function closeCard() { + card = null; + cardEl.style.display = 'none'; + } + function queueCard(sendNow) { + if (!card) return; + const text = cardText.value.trim(); + if (!text) { cardText.focus(); return; } + post({ + type: sendNow ? 'pc:queue-and-send' : 'pc:queue', + item: { kind: 'annotation', text, anchor: card.anchor } + }); + closeCard(); + } + cardEl.querySelector('.cancel').addEventListener('click', closeCard); + cardEl.querySelector('.queue').addEventListener('click', () => queueCard(false)); + cardText.addEventListener('keydown', e => { + if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { e.preventDefault(); queueCard(true); } + else if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); queueCard(false); } + else if (e.key === 'Escape') closeCard(); + }); + + // --- element hover / click --------------------------------------------- + document.addEventListener('mousemove', e => { + if (!annotate || card) { hl.style.display = 'none'; return; } + const el = e.target; + if (!el || isOurs(el) || el === document.body || el === document.documentElement || isInteractive(el)) { + hl.style.display = 'none'; + return; + } + const rect = el.getBoundingClientRect(); + hl.style.display = 'block'; + hl.style.left = rect.left - 2 + 'px'; + hl.style.top = rect.top - 2 + 'px'; + hl.style.width = rect.width + 'px'; + hl.style.height = rect.height + 'px'; + }, true); + + document.addEventListener('click', e => { + if (!annotate) return; + const el = e.target; + if (isOurs(el)) return; + if (card) { if (!cardEl.contains(e.composedPath()[0])) closeCard(); return; } + if (isInteractive(el)) return; // let controls behave natively + const selection = window.getSelection(); + if (selection && !selection.isCollapsed) return; // handled by selection flow + if (el === document.body || el === document.documentElement) return; + e.preventDefault(); + e.stopPropagation(); + hl.style.display = 'none'; + openCard({ + kindLabel: 'Annotate <' + el.tagName.toLowerCase() + '>', + anchor: { selector: selectorFor(el), tag: el.tagName.toLowerCase(), snippet: snippetFor(el) }, + x: e.clientX, + y: e.clientY + }); + }, true); + + // --- text selection ------------------------------------------------------- + document.addEventListener('mouseup', e => { + if (!annotate || card || isOurs(e.target)) return; + setTimeout(() => { + const selection = window.getSelection(); + const text = selection ? String(selection).replace(/\\s+/g, ' ').trim() : ''; + if (!text || !selection.rangeCount) { selhint.style.display = 'none'; return; } + const rect = selection.getRangeAt(0).getBoundingClientRect(); + selhint.style.display = 'block'; + selhint.style.left = rect.left + window.scrollX + 'px'; + selhint.style.top = rect.bottom + 6 + window.scrollY + 'px'; + selhint.onclick = () => { + selhint.style.display = 'none'; + const anchorNode = selection.anchorNode; + const el = anchorNode && anchorNode.nodeType === 1 ? anchorNode : anchorNode && anchorNode.parentElement; + openCard({ + kindLabel: 'Annotate selection', + anchor: { + selector: el ? selectorFor(el) : 'body', + tag: 'text', + snippet: text.slice(0, 200), + textRange: { text: text.slice(0, 1000) } + }, + x: rect.left, + y: rect.bottom + }); + }; + }, 0); + }, true); + document.addEventListener('selectionchange', () => { + const selection = window.getSelection(); + if (!selection || selection.isCollapsed) selhint.style.display = 'none'; + }); + + // --- chrome bridge --------------------------------------------------------- + window.addEventListener('message', e => { + const msg = e.data || {}; + if (msg.type === 'pc:set-mode') { + annotate = Boolean(msg.annotate); + if (!annotate) { hl.style.display = 'none'; selhint.style.display = 'none'; closeCard(); } + } else if (msg.type === 'pc:restore-scroll') { + window.scrollTo(msg.x || 0, msg.y || 0); + } + }); + document.addEventListener('keydown', e => { + if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'i') { + e.preventDefault(); + post({ type: 'pc:toggle-mode' }); + } else if (e.key === 'Escape' && card) closeCard(); + }, true); + + let scrollTimer = null; + window.addEventListener('scroll', () => { + if (scrollTimer) return; + scrollTimer = setTimeout(() => { + scrollTimer = null; + post({ type: 'pc:scroll', x: window.scrollX, y: window.scrollY }); + }, 150); + }, { passive: true }); + + post({ type: 'pc:ready' }); +})();`; +} + +module.exports = { artifactSdkJs }; diff --git a/scripts/lib/plan-canvas/server.js b/scripts/lib/plan-canvas/server.js new file mode 100644 index 000000000..28e7c2b70 --- /dev/null +++ b/scripts/lib/plan-canvas/server.js @@ -0,0 +1,532 @@ +'use strict'; + +/** + * Plan Canvas loopback server. + * + * One detached process serves every open review session: the browser chrome, + * the rendered artifact, an SSE stream for live updates, and the long-poll + * endpoint agents block on. Sessions are keyed by canonical artifact path + * (see sessions.js). + */ + +const { EventEmitter } = require('events'); +const fs = require('fs'); +const http = require('http'); +const path = require('path'); + +const { buildAllowedHostnames, isAllowedHostHeader, isAllowedOrigin } = require('../loopback-guard'); +const { renderMarkdown } = require('./markdown'); +const { artifactSdkJs } = require('./sdk'); +const { + canvasCss, + canvasClientJs, + renderCanvasHtml, + renderMarkdownArtifactHtml, + renderSessionListHtml +} = require('./ui'); + +const DEFAULT_PORT = 4517; +const DEFAULT_HOST = '127.0.0.1'; +const DEFAULT_IDLE_TIMEOUT_MS = 30 * 60 * 1000; +const MAX_BODY_BYTES = 1024 * 1024; + +const CONTENT_TYPES = { + '.css': 'text/css; charset=utf-8', + '.gif': 'image/gif', + '.html': 'text/html; charset=utf-8', + '.ico': 'image/x-icon', + '.jpeg': 'image/jpeg', + '.jpg': 'image/jpeg', + '.js': 'text/javascript; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.md': 'text/plain; charset=utf-8', + '.mjs': 'text/javascript; charset=utf-8', + '.png': 'image/png', + '.svg': 'image/svg+xml', + '.ttf': 'font/ttf', + '.txt': 'text/plain; charset=utf-8', + '.webp': 'image/webp', + '.woff': 'font/woff', + '.woff2': 'font/woff2' +}; + +function resolvePort(env = process.env) { + const value = Number.parseInt(env.ECC_PLAN_CANVAS_PORT || '', 10); + return Number.isInteger(value) && value >= 0 && value <= 65535 ? value : DEFAULT_PORT; +} + +function resolveIdleTimeoutMs(env = process.env) { + const raw = String(env.ECC_PLAN_CANVAS_IDLE_MS || '').trim().toLowerCase(); + if (raw === '0' || raw === 'off') return 0; + const value = Number.parseInt(raw, 10); + return Number.isInteger(value) && value > 0 ? value : DEFAULT_IDLE_TIMEOUT_MS; +} + +function readJsonBody(req) { + return new Promise((resolve, reject) => { + let size = 0; + const chunks = []; + req.on('data', chunk => { + size += chunk.length; + if (size > MAX_BODY_BYTES) { + reject(new Error('body too large')); + req.destroy(); + return; + } + chunks.push(chunk); + }); + req.on('end', () => { + if (chunks.length === 0) return resolve({}); + try { + resolve(JSON.parse(Buffer.concat(chunks).toString('utf8'))); + } catch { + reject(new Error('invalid JSON body')); + } + }); + req.on('error', reject); + }); +} + +function sendJson(res, statusCode, payload) { + const body = JSON.stringify(payload); + res.writeHead(statusCode, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }); + res.end(body); +} + +function sendHtml(res, statusCode, html, { csp = true } = {}) { + const headers = { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' }; + if (csp) { + headers['content-security-policy'] = + "default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; frame-src 'self'"; + } + res.writeHead(statusCode, headers); + res.end(html); +} + +function createPlanCanvasServer({ + store, + host = DEFAULT_HOST, + version = '0.0.0', + idleTimeoutMs = DEFAULT_IDLE_TIMEOUT_MS, + heartbeatMs = 15000, + onIdleShutdown = null, + log = () => {} +} = {}) { + if (!store) throw new Error('createPlanCanvasServer requires a session store'); + + const allowedHostnames = buildAllowedHostnames(host); + const wake = new EventEmitter(); + wake.setMaxListeners(0); + const sseClients = new Map(); // key -> Set + const awaitCounts = new Map(); // key -> active long-poll count + const workingKeys = new Set(); // keys whose agent took feedback and is off working + const watchers = new Map(); // key -> fs.FSWatcher + let idleTimer = null; + let closed = false; + + // --- presence + SSE --------------------------------------------------- + + function presenceFor(key) { + const session = store.get(key); + if (!session || session.status === 'ended') return 'ended'; + if ((awaitCounts.get(key) || 0) > 0) return 'listening'; + return workingKeys.has(key) ? 'working' : 'waiting'; + } + + function broadcast(key, event, payload) { + const clients = sseClients.get(key); + if (!clients) return; + const frameText = `event: ${event}\ndata: ${JSON.stringify(payload)}\n\n`; + for (const client of clients) client.write(frameText); + } + + function broadcastPresence(key) { + broadcast(key, 'presence', { state: presenceFor(key) }); + } + + function connectionCount() { + let total = 0; + for (const clients of sseClients.values()) total += clients.size; + for (const count of awaitCounts.values()) total += count; + return total; + } + + function armIdleTimer() { + if (!idleTimeoutMs || closed) return; + if (connectionCount() > 0) return; + clearTimeout(idleTimer); + idleTimer = setTimeout(() => { + if (connectionCount() === 0 && !closed) { + log('[plan-canvas] idle timeout reached, shutting down'); + if (onIdleShutdown) onIdleShutdown(); + } + }, idleTimeoutMs); + if (idleTimer.unref) idleTimer.unref(); + } + + function noteConnectionOpened() { + clearTimeout(idleTimer); + } + + function noteConnectionClosed() { + armIdleTimer(); + } + + // --- artifact watching -------------------------------------------------- + + function watchSession(session) { + if (watchers.has(session.key)) return; + const dir = path.dirname(session.file); + const base = path.basename(session.file); + let debounce = null; + try { + const watcher = fs.watch(dir, (eventType, filename) => { + if (filename && filename !== base) return; + clearTimeout(debounce); + debounce = setTimeout(() => broadcast(session.key, 'reload', {}), 150); + }); + watcher.on('error', () => watchers.delete(session.key)); + watchers.set(session.key, watcher); + } catch { + // Watching is best-effort; manual reload still works. + } + } + + function unwatchSession(key) { + const watcher = watchers.get(key); + if (watcher) { + watcher.close(); + watchers.delete(key); + } + } + + // --- session actions ------------------------------------------------------ + + function endSession(key, endedBy) { + const session = store.end(key, endedBy); + if (!session) return null; + wake.emit(`wake:${key}`); + broadcast(key, 'ended', { endedBy: session.endedBy }); + broadcastPresence(key); + unwatchSession(key); + return session; + } + + // --- request handlers ------------------------------------------------------- + + async function handleApi(req, res, url) { + const { pathname } = url; + + if (req.method === 'POST' && pathname === '/api/sessions') { + const body = await readJsonBody(req); + if (!body.file || typeof body.file !== 'string') { + return sendJson(res, 400, { error: 'file is required' }); + } + if (!fs.existsSync(path.resolve(body.file))) { + return sendJson(res, 404, { error: `artifact not found: ${body.file}` }); + } + const { session, refused } = store.open(body.file, { reopen: Boolean(body.reopen) }); + if (refused) { + return sendJson(res, 409, { + status: 'user-ended', + key: session.key, + next_step: 'The user ended this review from the browser. Do not reopen it unless they ask; pass reopen:true when they do.' + }); + } + watchSession(session); + broadcastPresence(session.key); + return sendJson(res, 200, { + status: 'open', + key: session.key, + file: session.file, + url: `/canvas/${session.key}` + }); + } + + if (req.method === 'GET' && pathname === '/api/sessions') { + return sendJson(res, 200, { sessions: store.list() }); + } + + if (req.method === 'GET' && pathname === '/api/await') { + const file = url.searchParams.get('file'); + if (!file) return sendJson(res, 400, { error: 'file query parameter is required' }); + const session = store.findByFile(file); + if (!session) return sendJson(res, 200, { status: 'missing' }); + const key = session.key; + const timeoutRaw = url.searchParams.get('timeoutMs'); + const timeoutMs = timeoutRaw === null ? null : Math.max(0, Number.parseInt(timeoutRaw, 10) || 0); + + const first = store.takeFeedback(key); + if (first.status !== 'waiting') { + if (first.status === 'feedback') workingKeys.add(key); + broadcastPresence(key); + return sendJson(res, 200, first); + } + + // Long poll: hold the request open until feedback or session end. + noteConnectionOpened(); + awaitCounts.set(key, (awaitCounts.get(key) || 0) + 1); + workingKeys.delete(key); + broadcastPresence(key); + + let settled = false; + let heartbeat = null; + let waitTimer = null; + const finish = payload => { + if (settled) return; + settled = true; + cleanup(); + if (payload) { + if (payload.status === 'feedback') workingKeys.add(key); + res.end(JSON.stringify(payload)); + } + broadcastPresence(key); + noteConnectionClosed(); + }; + const onWake = () => { + const result = store.takeFeedback(key); + if (result.status !== 'waiting') finish(result); + }; + // Settle held polls on shutdown so server.close() can complete; the + // CLI tells agents to simply re-run await. + const onServerClose = () => + finish({ status: 'waiting', note: 'canvas server is shutting down; re-run await' }); + const cleanup = () => { + wake.removeListener(`wake:${key}`, onWake); + wake.removeListener('server-close', onServerClose); + clearInterval(heartbeat); + clearTimeout(waitTimer); + awaitCounts.set(key, Math.max(0, (awaitCounts.get(key) || 1) - 1)); + }; + + res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }); + // Leading whitespace keeps the connection visibly alive without + // corrupting the JSON payload written at the end. + res.write(' '); + heartbeat = setInterval(() => { + if (!settled) res.write(' '); + }, heartbeatMs); + if (timeoutMs !== null) { + waitTimer = setTimeout(() => finish({ status: 'waiting' }), timeoutMs); + } + wake.on(`wake:${key}`, onWake); + wake.once('server-close', onServerClose); + req.on('close', () => finish(null)); + return undefined; + } + + if (req.method === 'POST' && pathname === '/api/end') { + const body = await readJsonBody(req); + if (!body.file || typeof body.file !== 'string') { + return sendJson(res, 400, { error: 'file is required' }); + } + const session = store.findByFile(body.file); + if (!session) return sendJson(res, 404, { error: 'no session for that file' }); + endSession(session.key, 'agent'); + return sendJson(res, 200, { status: 'ended', endedBy: 'agent' }); + } + + const sessionMatch = pathname.match(/^\/api\/session\/([a-f0-9]{12})\/(feedback|end|reply)$/); + if (sessionMatch && req.method === 'POST') { + const [, key, action] = sessionMatch; + const session = store.get(key); + if (!session) return sendJson(res, 404, { error: 'unknown session' }); + + if (action === 'feedback') { + const body = await readJsonBody(req); + const result = store.queueFeedback(key, body.items, { endSession: Boolean(body.endSession) }); + if (!result) return sendJson(res, 409, { error: 'session already ended' }); + wake.emit(`wake:${key}`); + broadcast(key, 'chat-sync', { chat: store.get(key).chat }); + if (body.endSession) broadcast(key, 'ended', { endedBy: 'user' }); + return sendJson(res, 200, { status: 'queued', accepted: result.accepted.length, pending: result.pending }); + } + + if (action === 'end') { + endSession(key, 'user'); + return sendJson(res, 200, { status: 'ended', endedBy: 'user' }); + } + + if (action === 'reply') { + const body = await readJsonBody(req); + if (!body.text || typeof body.text !== 'string') { + return sendJson(res, 400, { error: 'text is required' }); + } + const entry = store.addAgentReply(key, body.text); + broadcast(key, 'chat-sync', { chat: store.get(key).chat }); + return sendJson(res, 200, { status: 'sent', at: entry.at }); + } + } + + return sendJson(res, 404, { error: 'not found' }); + } + + function handleEvents(req, res, key) { + const session = store.get(key); + if (!session) return sendJson(res, 404, { error: 'unknown session' }); + noteConnectionOpened(); + res.writeHead(200, { + 'content-type': 'text/event-stream', + 'cache-control': 'no-store', + connection: 'keep-alive' + }); + res.write(`event: chat-sync\ndata: ${JSON.stringify({ chat: session.chat })}\n\n`); + res.write(`event: presence\ndata: ${JSON.stringify({ state: presenceFor(key) })}\n\n`); + if (!sseClients.has(key)) sseClients.set(key, new Set()); + sseClients.get(key).add(res); + const ping = setInterval(() => res.write(': ping\n\n'), 25000); + if (ping.unref) ping.unref(); + req.on('close', () => { + clearInterval(ping); + const clients = sseClients.get(key); + if (clients) { + clients.delete(res); + if (clients.size === 0) sseClients.delete(key); + } + noteConnectionClosed(); + }); + } + + function serveArtifact(res, key, assetPath) { + const session = store.get(key); + if (!session) return sendHtml(res, 404, '

    Unknown session

    '); + + if (!assetPath) { + let content; + try { + content = fs.readFileSync(session.file, 'utf8'); + } catch { + return sendHtml(res, 404, `

    Artifact missing

    ${session.file} no longer exists.

    `, { csp: false }); + } + const ext = path.extname(session.file).toLowerCase(); + if (ext === '.md' || ext === '.markdown') { + const html = renderMarkdownArtifactHtml(renderMarkdown(content), { + title: path.basename(session.file), + sdkSrc: '/sdk.js' + }); + return sendHtml(res, 200, html, { csp: false }); + } + const sdkTag = ''; + const injected = content.includes('') + ? content.replace('', `${sdkTag}\n`) + : `${content}\n${sdkTag}`; + return sendHtml(res, 200, injected, { csp: false }); + } + + // Sibling assets resolve relative to the artifact's directory and must + // stay confined to it. + const baseDir = path.dirname(session.file); + const resolved = path.resolve(baseDir, assetPath); + if (resolved !== baseDir && !resolved.startsWith(baseDir + path.sep)) { + return sendJson(res, 403, { error: 'asset path escapes artifact directory' }); + } + let data; + try { + data = fs.readFileSync(resolved); + } catch { + return sendJson(res, 404, { error: 'asset not found' }); + } + const type = CONTENT_TYPES[path.extname(resolved).toLowerCase()] || 'application/octet-stream'; + res.writeHead(200, { 'content-type': type, 'cache-control': 'no-store' }); + return res.end(data); + } + + const server = http.createServer((req, res) => { + if (!isAllowedHostHeader(req.headers.host, allowedHostnames)) { + return sendJson(res, 403, { error: 'forbidden host header' }); + } + if (!isAllowedOrigin(req.headers.origin, allowedHostnames)) { + return sendJson(res, 403, { error: 'forbidden origin' }); + } + const url = new URL(req.url, `http://${req.headers.host}`); + const { pathname } = url; + + Promise.resolve() + .then(() => { + if (req.method === 'GET' && pathname === '/health') { + return sendJson(res, 200, { ok: true, app: 'ecc-plan-canvas', version }); + } + if (req.method === 'POST' && pathname === '/shutdown') { + sendJson(res, 200, { status: 'stopping' }); + setImmediate(() => { + if (onIdleShutdown) onIdleShutdown(); + }); + return undefined; + } + if (req.method === 'GET' && pathname === '/') { + return sendHtml(res, 200, renderSessionListHtml(store.list())); + } + if (req.method === 'GET' && pathname === '/canvas.css') { + res.writeHead(200, { 'content-type': 'text/css; charset=utf-8', 'cache-control': 'no-store' }); + return res.end(canvasCss()); + } + if (req.method === 'GET' && pathname === '/client.js') { + res.writeHead(200, { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': 'no-store' }); + return res.end(canvasClientJs()); + } + if (req.method === 'GET' && pathname === '/sdk.js') { + res.writeHead(200, { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': 'no-store' }); + return res.end(artifactSdkJs()); + } + const canvasMatch = pathname.match(/^\/canvas\/([a-f0-9]{12})$/); + if (req.method === 'GET' && canvasMatch) { + const session = store.get(canvasMatch[1]); + if (!session) return sendHtml(res, 404, '

    Unknown session

    '); + return sendHtml(res, 200, renderCanvasHtml(session)); + } + const eventsMatch = pathname.match(/^\/events\/([a-f0-9]{12})$/); + if (req.method === 'GET' && eventsMatch) { + return handleEvents(req, res, eventsMatch[1]); + } + const artifactMatch = pathname.match(/^\/artifact\/([a-f0-9]{12})\/(.*)$/); + if (req.method === 'GET' && artifactMatch) { + const assetPath = decodeURIComponent(artifactMatch[2]); + return serveArtifact(res, artifactMatch[1], assetPath || null); + } + if (pathname.startsWith('/api/')) { + return handleApi(req, res, url); + } + return sendJson(res, 404, { error: 'not found' }); + }) + .catch(error => { + if (!res.headersSent) sendJson(res, 400, { error: error.message }); + else res.end(); + }); + }); + + function close() { + closed = true; + clearTimeout(idleTimer); + for (const key of watchers.keys()) unwatchSession(key); + for (const clients of sseClients.values()) { + for (const client of clients) client.end(); + } + sseClients.clear(); + wake.emit('server-close'); + return new Promise((resolve, reject) => { + server.close(error => (error ? reject(error) : resolve())); + // Browser keep-alive sockets would otherwise hold close() open. + if (typeof server.closeIdleConnections === 'function') server.closeIdleConnections(); + }); + } + + function listen(port = resolvePort()) { + return new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(port, host, () => { + armIdleTimer(); + resolve({ port: server.address().port, host }); + }); + }); + } + + return { server, listen, close, presenceFor, watchSession }; +} + +module.exports = { + DEFAULT_HOST, + DEFAULT_PORT, + createPlanCanvasServer, + resolveIdleTimeoutMs, + resolvePort +}; diff --git a/scripts/lib/plan-canvas/sessions.js b/scripts/lib/plan-canvas/sessions.js new file mode 100644 index 000000000..799cf4b36 --- /dev/null +++ b/scripts/lib/plan-canvas/sessions.js @@ -0,0 +1,269 @@ +'use strict'; + +/** + * Plan Canvas session store. + * + * Sessions are keyed by the canonical artifact file path so agents never + * juggle opaque ids. State is persisted as JSON in the Plan Canvas state + * dir so queued human feedback survives a server restart. + */ + +const crypto = require('crypto'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const FEEDBACK_KINDS = new Set(['chat', 'annotation', 'verdict']); +const VERDICTS = new Set(['approve', 'request-changes']); + +function resolveStateDir(env = process.env) { + const override = env.ECC_PLAN_CANVAS_STATE_DIR; + if (override && String(override).trim()) return path.resolve(String(override).trim()); + return path.join(os.homedir(), '.claude', 'plan-canvas'); +} + +// Canonicalize so `./plan.md`, symlinks, and absolute paths all land on the +// same session. +function canonicalizeArtifactPath(filePath) { + const absolute = path.resolve(filePath); + try { + return fs.realpathSync(absolute); + } catch { + return absolute; + } +} + +function sessionKeyFor(canonicalPath) { + return crypto.createHash('sha256').update(canonicalPath).digest('hex').slice(0, 12); +} + +function nowIso() { + return new Date().toISOString(); +} + +function sanitizeText(value, maxLength = 4000) { + if (typeof value !== 'string') return ''; + return value.slice(0, maxLength); +} + +// Normalize one browser-submitted feedback item into the shape delivered to +// the agent. Returns null for unusable input rather than throwing so a +// malformed item can never wedge the queue. +function normalizeFeedbackItem(raw, counter) { + if (!raw || typeof raw !== 'object') return null; + const kind = FEEDBACK_KINDS.has(raw.kind) ? raw.kind : null; + if (!kind) return null; + const item = { + id: `fb-${counter}`, + kind, + text: sanitizeText(raw.text), + at: nowIso() + }; + if (kind === 'verdict') { + if (!VERDICTS.has(raw.verdict)) return null; + item.verdict = raw.verdict; + } + if (kind === 'annotation') { + const anchor = raw.anchor && typeof raw.anchor === 'object' ? raw.anchor : null; + if (!anchor || typeof anchor.selector !== 'string') return null; + item.anchor = { + selector: sanitizeText(anchor.selector, 500), + tag: sanitizeText(anchor.tag, 60), + snippet: sanitizeText(anchor.snippet, 400) + }; + if (anchor.textRange && typeof anchor.textRange === 'object') { + item.anchor.textRange = { + text: sanitizeText(anchor.textRange.text, 1000) + }; + } + if (!item.text) return null; + } + if (kind === 'chat' && !item.text) return null; + return item; +} + +function createSessionStore({ stateDir = resolveStateDir() } = {}) { + const stateFile = path.join(stateDir, 'sessions.json'); + let state = { sessions: {}, feedbackCounter: 0 }; + + function load() { + try { + const parsed = JSON.parse(fs.readFileSync(stateFile, 'utf8')); + if (parsed && typeof parsed === 'object' && parsed.sessions) { + state = { + sessions: parsed.sessions, + feedbackCounter: Number(parsed.feedbackCounter) || 0 + }; + } + } catch { + // Missing or corrupt state starts fresh; queued feedback loss on a + // corrupt file beats refusing to start at all. + } + } + + function persist() { + fs.mkdirSync(stateDir, { recursive: true }); + const tmpFile = `${stateFile}.tmp`; + fs.writeFileSync(tmpFile, JSON.stringify(state, null, 2)); + fs.renameSync(tmpFile, stateFile); + } + + load(); + + function get(key) { + return state.sessions[key] || null; + } + + function findByFile(filePath) { + const canonical = canonicalizeArtifactPath(filePath); + return get(sessionKeyFor(canonical)); + } + + // Open (or resume) a session. A session the *user* ended from the browser + // is sticky: it refuses a plain reopen so agents do not pop the browser + // back up uninvited. Pass reopen:true only when the human asked. + function open(filePath, { reopen = false } = {}) { + const canonical = canonicalizeArtifactPath(filePath); + const key = sessionKeyFor(canonical); + const existing = state.sessions[key]; + if (existing && existing.status === 'ended' && existing.endedBy === 'user' && !reopen) { + return { session: existing, refused: true }; + } + const session = existing || { + key, + file: canonical, + chat: [], + pendingFeedback: [], + createdAt: nowIso() + }; + session.status = 'open'; + delete session.endedBy; + session.updatedAt = nowIso(); + state.sessions[key] = session; + persist(); + return { session, refused: false }; + } + + // Queue feedback from the browser. Chat-shaped items are mirrored into the + // session transcript immediately so the conversation panel stays coherent + // across reloads. + function queueFeedback(key, rawItems, { endSession = false } = {}) { + const session = get(key); + if (!session || session.status === 'ended') return null; + const accepted = []; + for (const raw of Array.isArray(rawItems) ? rawItems : []) { + state.feedbackCounter += 1; + const item = normalizeFeedbackItem(raw, state.feedbackCounter); + if (item) accepted.push(item); + } + session.pendingFeedback.push(...accepted); + for (const item of accepted) { + session.chat.push({ role: 'user', kind: item.kind, text: chatLineFor(item), at: item.at }); + } + if (endSession) { + session.status = 'ended'; + session.endedBy = 'user'; + } else if (accepted.length > 0) { + session.status = 'feedback'; + } + session.updatedAt = nowIso(); + persist(); + return { accepted, pending: session.pendingFeedback.length, session }; + } + + // Deliver-and-drain: feedback is handed to exactly one await call, after + // which the session flips back to open. An ended session keeps reporting + // ended (with attribution) so agents know to stop polling. + function takeFeedback(key) { + const session = get(key); + if (!session) return { status: 'missing' }; + if (session.pendingFeedback.length > 0) { + const items = session.pendingFeedback; + session.pendingFeedback = []; + const result = { status: 'feedback', items }; + if (session.status === 'ended') { + result.sessionEnded = true; + result.endedBy = session.endedBy; + } else { + session.status = 'open'; + } + session.updatedAt = nowIso(); + persist(); + return result; + } + if (session.status === 'ended') { + return { status: 'ended', endedBy: session.endedBy }; + } + return { status: 'waiting' }; + } + + function addAgentReply(key, text) { + const session = get(key); + if (!session) return null; + const entry = { role: 'agent', kind: 'chat', text: sanitizeText(text), at: nowIso() }; + session.chat.push(entry); + session.updatedAt = nowIso(); + persist(); + return entry; + } + + function end(key, endedBy) { + const session = get(key); + if (!session) return null; + session.status = 'ended'; + session.endedBy = endedBy === 'user' ? 'user' : 'agent'; + session.updatedAt = nowIso(); + persist(); + return session; + } + + function list() { + return Object.values(state.sessions).map(session => ({ + key: session.key, + file: session.file, + status: session.status, + endedBy: session.endedBy, + pending: session.pendingFeedback.length, + updatedAt: session.updatedAt + })); + } + + function hasOpenSessions() { + return Object.values(state.sessions).some(session => session.status !== 'ended'); + } + + return { + stateDir, + stateFile, + open, + get, + findByFile, + queueFeedback, + takeFeedback, + addAgentReply, + end, + list, + hasOpenSessions + }; +} + +// One-line rendering of a feedback item for the conversation transcript. +function chatLineFor(item) { + if (item.kind === 'verdict') { + const label = item.verdict === 'approve' ? 'Approved the plan' : 'Requested changes'; + return item.text ? `${label}: ${item.text}` : label; + } + if (item.kind === 'annotation') { + const where = item.anchor.snippet || item.anchor.selector; + return `[${where}] ${item.text}`; + } + return item.text; +} + +module.exports = { + canonicalizeArtifactPath, + createSessionStore, + normalizeFeedbackItem, + resolveStateDir, + sessionKeyFor +}; diff --git a/scripts/lib/plan-canvas/ui.js b/scripts/lib/plan-canvas/ui.js new file mode 100644 index 000000000..0432282f6 --- /dev/null +++ b/scripts/lib/plan-canvas/ui.js @@ -0,0 +1,542 @@ +'use strict'; + +/** + * Plan Canvas browser chrome: the editor shell that frames an artifact, + * plus the rendered-markdown artifact template. + * + * Visual language mirrors the ECC web dashboard (scripts/dashboard-web.js): + * same design tokens, dark-first with a light theme, accent→pink brand + * gradient. Everything is served inline — no CDNs, no external assets. + */ + +const path = require('path'); + +const { escapeHtml } = require('./markdown'); + +// Pinned Mermaid ESM build, loaded in the browser only when an artifact +// actually contains a diagram. Override with a local/vendored URL (e.g. an +// air-gapped mirror) via ECC_PLAN_CANVAS_MERMAID_URL. If the fetch fails, the +// diagram source stays visible as a styled code block — nothing breaks. +const DEFAULT_MERMAID_URL = 'https://cdn.jsdelivr.net/npm/mermaid@11.4.1/dist/mermaid.esm.min.mjs'; + +function mermaidUrl(env = process.env) { + const override = env.ECC_PLAN_CANVAS_MERMAID_URL; + return override && String(override).trim() ? String(override).trim() : DEFAULT_MERMAID_URL; +} + +// Browser module that renders `
    ` blocks, themed to match
    +// the ECC canvas. Kept import-only so a CDN failure degrades gracefully.
    +function mermaidLoaderScript(url) {
    +  return ``;
    +}
    +
    +// Design tokens shared by the chrome and the markdown artifact template.
    +const TOKENS_CSS = `
    +  :root{
    +    --bg:#080a0e; --bg2:#0d0f14; --bg3:#13161e; --bg4:#191d2a;
    +    --surface:#101218; --surface-hover:#171a24; --border:#1d2130; --border-light:#272c3e;
    +    --text:#dfe2e9; --text2:#80859a; --text3:#4c5168;
    +    --accent:#6885e8; --accent-glow:rgba(104,133,232,0.15); --accent-dim:#3d5ab8;
    +    --green:#4acb8a; --green-glow:rgba(74,203,138,0.15);
    +    --orange:#eca85a; --orange-glow:rgba(236,168,90,0.15);
    +    --pink:#e26a9e; --pink-glow:rgba(226,106,158,0.15);
    +    --red:#e86060; --red-glow:rgba(232,96,96,0.15);
    +    --teal:#4acbbe; --teal-glow:rgba(74,203,190,0.15);
    +    --radius:8px; --radius-sm:5px;
    +    --font:-apple-system,BlinkMacSystemFont,'SF Pro Display','Inter','Segoe UI',Roboto,sans-serif;
    +    --mono:'SF Mono','Fira Code','JetBrains Mono','Cascadia Code',monospace;
    +    --shadow:0 1px 2px rgba(0,0,0,0.4);
    +    --shadow-lg:0 8px 32px rgba(0,0,0,0.6);
    +  }
    +  [data-theme="light"]{
    +    --bg:#f4f5f7; --bg2:#ffffff; --bg3:#eaecef; --bg4:#dfe2e6;
    +    --surface:#ffffff; --surface-hover:#f4f5f7; --border:#cdd1d9; --border-light:#dde1e8;
    +    --text:#181b23; --text2:#585e6e; --text3:#9197a8;
    +    --accent:#4560d0; --accent-glow:rgba(69,96,208,0.08); --accent-dim:#2f44a0;
    +    --green:#16a34a; --green-glow:rgba(22,163,74,0.08);
    +    --orange:#d97706; --orange-glow:rgba(217,119,6,0.08);
    +    --pink:#c73877; --pink-glow:rgba(199,56,119,0.08);
    +    --red:#dc2626; --red-glow:rgba(220,38,38,0.08);
    +    --teal:#0d9488; --teal-glow:rgba(13,148,136,0.08);
    +    --shadow:0 1px 2px rgba(0,0,0,0.04);
    +    --shadow-lg:0 8px 32px rgba(0,0,0,0.08);
    +  }
    +`;
    +
    +function canvasCss() {
    +  return `${TOKENS_CSS}
    +  *{margin:0;padding:0;box-sizing:border-box}
    +  html,body{height:100%}
    +  body{font-family:var(--font);background:var(--bg);color:var(--text);-webkit-font-smoothing:antialiased;line-height:1.4;overflow:hidden}
    +  ::selection{background:var(--accent);color:#fff}
    +  ::-webkit-scrollbar{width:8px;height:8px}
    +  ::-webkit-scrollbar-track{background:transparent}
    +  ::-webkit-scrollbar-thumb{background:var(--border);border-radius:4px}
    +  button{font-family:var(--font)}
    +
    +  .bar{display:flex;align-items:center;gap:12px;height:52px;padding:0 16px;background:color-mix(in srgb,var(--bg2) 88%,transparent);border-bottom:1px solid var(--border);backdrop-filter:blur(16px)}
    +  .brand{display:flex;align-items:center;gap:9px;min-width:0}
    +  .brand .logo{width:26px;height:26px;flex:none;background:linear-gradient(135deg,var(--accent),var(--pink));border-radius:6px;display:flex;align-items:center;justify-content:center;font-size:13px;font-weight:700;color:#fff}
    +  .brand .name{font-size:13.5px;font-weight:600;white-space:nowrap}
    +  .brand .file{font-size:11.5px;color:var(--text2);font-family:var(--mono);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:34vw}
    +  .bar .spacer{flex:1}
    +
    +  .presence{display:flex;align-items:center;gap:6px;font-size:11px;font-weight:500;color:var(--text2);background:var(--bg3);border:1px solid var(--border);border-radius:99px;padding:3px 10px 3px 8px;white-space:nowrap}
    +  .presence .dot{width:7px;height:7px;border-radius:99px;background:var(--text3)}
    +  .presence[data-state="listening"] .dot{background:var(--green);box-shadow:0 0 0 3px var(--green-glow);animation:pulse 2s infinite}
    +  .presence[data-state="working"] .dot{background:var(--orange);box-shadow:0 0 0 3px var(--orange-glow)}
    +  @keyframes pulse{0%,100%{opacity:1}50%{opacity:.45}}
    +
    +  .toggle{display:flex;align-items:center;gap:7px;font-size:11.5px;color:var(--text2);cursor:pointer;user-select:none}
    +  .toggle .track{width:30px;height:17px;border-radius:99px;background:var(--bg4);border:1px solid var(--border);position:relative;transition:background .15s}
    +  .toggle .knob{position:absolute;top:1px;left:1px;width:13px;height:13px;border-radius:99px;background:var(--text2);transition:transform .15s,background .15s}
    +  .toggle[aria-pressed="true"] .track{background:var(--accent);border-color:var(--accent-dim)}
    +  .toggle[aria-pressed="true"] .knob{transform:translateX(13px);background:#fff}
    +
    +  .icon-btn{height:28px;padding:0 10px;border-radius:6px;border:1px solid var(--border);background:var(--bg3);color:var(--text2);cursor:pointer;font-size:11.5px;display:flex;align-items:center;gap:5px;transition:all .12s}
    +  .icon-btn:hover{border-color:var(--border-light);color:var(--text);background:var(--bg4)}
    +  .icon-btn.danger:hover{border-color:var(--red);color:var(--red);background:var(--red-glow)}
    +
    +  .layout{display:flex;height:calc(100% - 52px)}
    +  .frame{flex:1;min-width:0;position:relative;background:var(--bg2)}
    +  .frame iframe{width:100%;height:100%;border:0;background:#fff}
    +  [data-theme] .frame iframe{background:var(--bg2)}
    +
    +  .panel{width:340px;flex:none;display:flex;flex-direction:column;border-left:1px solid var(--border);background:var(--bg2)}
    +  .panel h2{font-size:11px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;color:var(--text3);padding:12px 14px 8px}
    +
    +  .verdict{display:flex;gap:8px;padding:0 14px 12px;border-bottom:1px solid var(--border)}
    +  .verdict button{flex:1;height:30px;border-radius:6px;font-size:12px;font-weight:600;cursor:pointer;transition:all .12s}
    +  .verdict .approve{border:1px solid var(--green);background:var(--green-glow);color:var(--green)}
    +  .verdict .approve:hover{background:var(--green);color:#fff}
    +  .verdict .changes{border:1px solid var(--orange);background:var(--orange-glow);color:var(--orange)}
    +  .verdict .changes:hover{background:var(--orange);color:#fff}
    +
    +  .chat{flex:1;overflow-y:auto;padding:10px 14px;display:flex;flex-direction:column;gap:8px}
    +  .msg{max-width:92%;padding:7px 10px;border-radius:10px;font-size:12.5px;white-space:pre-wrap;word-break:break-word}
    +  .msg.user{align-self:flex-end;background:var(--accent-glow);border:1px solid color-mix(in srgb,var(--accent) 35%,transparent);color:var(--text);border-bottom-right-radius:3px}
    +  .msg.agent{align-self:flex-start;background:var(--bg3);border:1px solid var(--border);color:var(--text);border-bottom-left-radius:3px}
    +  .msg .meta{display:block;font-size:9.5px;color:var(--text3);margin-top:3px}
    +  .msg.kind-annotation{border-left:2px solid var(--teal)}
    +  .msg.kind-verdict{border-left:2px solid var(--green)}
    +  .chat .empty{color:var(--text3);font-size:12px;text-align:center;margin-top:24px;line-height:1.6}
    +
    +  .queue{padding:8px 14px 0;display:flex;flex-direction:column;gap:6px;max-height:180px;overflow-y:auto}
    +  .pill{display:flex;align-items:flex-start;gap:8px;background:var(--bg3);border:1px solid var(--border);border-left:2px solid var(--teal);border-radius:6px;padding:6px 8px;font-size:11.5px}
    +  .pill.kind-chat{border-left-color:var(--accent)}
    +  .pill.kind-verdict{border-left-color:var(--green)}
    +  .pill .where{color:var(--teal);font-family:var(--mono);font-size:10px;display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
    +  .pill .body{flex:1;min-width:0;color:var(--text2)}
    +  .pill .txt{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text)}
    +  .pill button{border:none;background:none;color:var(--text3);cursor:pointer;font-size:13px;line-height:1;padding:1px}
    +  .pill button:hover{color:var(--red)}
    +
    +  .composer{padding:10px 14px 14px;border-top:1px solid var(--border);display:flex;flex-direction:column;gap:8px}
    +  .composer .hint{font-size:10px;color:var(--text3)}
    +  .composer textarea{width:100%;min-height:60px;max-height:160px;resize:vertical;background:var(--bg3);border:1px solid var(--border);border-radius:6px;padding:8px 10px;color:var(--text);font-size:12.5px;font-family:var(--font);outline:none;transition:all .15s}
    +  .composer textarea:focus{border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-glow)}
    +  .composer .row{display:flex;gap:8px;align-items:center}
    +  .composer .send{flex:1;height:32px;border:none;border-radius:6px;background:var(--accent);color:#fff;font-size:12.5px;font-weight:600;cursor:pointer;transition:all .12s}
    +  .composer .send:hover{background:var(--accent-dim)}
    +  .composer .send:disabled{opacity:.5;cursor:default}
    +  .composer .status{font-size:10.5px;color:var(--text3)}
    +
    +  .overlay{position:absolute;inset:0;display:none;align-items:center;justify-content:center;background:color-mix(in srgb,var(--bg) 80%,transparent);backdrop-filter:blur(6px);z-index:50}
    +  .overlay.show{display:flex}
    +  .overlay .card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);box-shadow:var(--shadow-lg);padding:26px 32px;text-align:center;max-width:340px}
    +  .overlay .card h3{font-size:14px;margin-bottom:6px}
    +  .overlay .card p{font-size:12px;color:var(--text2);line-height:1.5}
    +  `;
    +}
    +
    +// Client logic for the chrome page (runs in the top window).
    +function canvasClientJs() {
    +  return `'use strict';
    +(() => {
    +  const boot = JSON.parse(document.getElementById('pc-session').textContent);
    +  const key = boot.key;
    +  const $ = id => document.getElementById(id);
    +  const frame = $('artifact');
    +  const chatLog = $('chatLog');
    +  const queueEl = $('queue');
    +  const input = $('chatInput');
    +  const sendBtn = $('send');
    +  const statusEl = $('sendStatus');
    +  const presence = $('presence');
    +  const QKEY = 'ecc-plan-canvas:queue:' + key;
    +  let queue = [];
    +  let lastScroll = { x: 0, y: 0 };
    +  let ended = boot.status === 'ended';
    +  let sending = false;
    +
    +  try { queue = JSON.parse(sessionStorage.getItem(QKEY) || '[]'); } catch { queue = []; }
    +
    +  // --- theme ---------------------------------------------------------
    +  const themeKey = 'ecc-plan-canvas:theme';
    +  function applyTheme(t) {
    +    if (t === 'light') document.documentElement.setAttribute('data-theme', 'light');
    +    else document.documentElement.removeAttribute('data-theme');
    +    $('themeBtn').textContent = t === 'light' ? '\\u263E dark' : '\\u2600 light';
    +  }
    +  let theme = localStorage.getItem(themeKey) || 'dark';
    +  applyTheme(theme);
    +  $('themeBtn').addEventListener('click', () => {
    +    theme = theme === 'light' ? 'dark' : 'light';
    +    localStorage.setItem(themeKey, theme);
    +    applyTheme(theme);
    +  });
    +
    +  // --- annotate mode -------------------------------------------------
    +  let annotate = true;
    +  function setAnnotate(on) {
    +    annotate = on;
    +    $('annotate').setAttribute('aria-pressed', String(on));
    +    postToFrame({ type: 'pc:set-mode', annotate: on });
    +  }
    +  $('annotate').addEventListener('click', () => setAnnotate(!annotate));
    +  document.addEventListener('keydown', e => {
    +    if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'i') {
    +      e.preventDefault();
    +      setAnnotate(!annotate);
    +    }
    +  }, true);
    +
    +  // --- iframe bridge --------------------------------------------------
    +  function postToFrame(msg) {
    +    if (frame.contentWindow) frame.contentWindow.postMessage(msg, '*');
    +  }
    +  window.addEventListener('message', e => {
    +    if (e.source !== frame.contentWindow) return;
    +    const msg = e.data || {};
    +    if (msg.type === 'pc:queue' && msg.item) addToQueue(msg.item);
    +    else if (msg.type === 'pc:queue-and-send' && msg.item) { addToQueue(msg.item); send(); }
    +    else if (msg.type === 'pc:scroll') lastScroll = { x: msg.x || 0, y: msg.y || 0 };
    +    else if (msg.type === 'pc:toggle-mode') setAnnotate(!annotate);
    +    else if (msg.type === 'pc:ready') {
    +      postToFrame({ type: 'pc:set-mode', annotate });
    +      postToFrame({ type: 'pc:restore-scroll', x: lastScroll.x, y: lastScroll.y });
    +    }
    +  });
    +
    +  // --- queue ----------------------------------------------------------
    +  function persistQueue() { try { sessionStorage.setItem(QKEY, JSON.stringify(queue)); } catch { /* full */ } }
    +  function addToQueue(item) { queue.push(item); persistQueue(); renderQueue(); }
    +  function renderQueue() {
    +    queueEl.innerHTML = '';
    +    queue.forEach((item, i) => {
    +      const pill = document.createElement('div');
    +      pill.className = 'pill kind-' + item.kind;
    +      const body = document.createElement('span');
    +      body.className = 'body';
    +      if (item.anchor) {
    +        const where = document.createElement('span');
    +        where.className = 'where';
    +        where.textContent = item.anchor.snippet || item.anchor.selector;
    +        body.appendChild(where);
    +      }
    +      const txt = document.createElement('span');
    +      txt.className = 'txt';
    +      txt.textContent = item.kind === 'verdict' ? (item.verdict === 'approve' ? 'Approve plan' : 'Request changes') + (item.text ? ': ' + item.text : '') : item.text;
    +      body.appendChild(txt);
    +      const rm = document.createElement('button');
    +      rm.textContent = '\\u00D7';
    +      rm.title = 'Remove';
    +      rm.addEventListener('click', () => { queue.splice(i, 1); persistQueue(); renderQueue(); });
    +      pill.append(body, rm);
    +      queueEl.appendChild(pill);
    +    });
    +  }
    +  renderQueue();
    +
    +  // --- chat -----------------------------------------------------------
    +  function renderChat(entries) {
    +    chatLog.innerHTML = '';
    +    if (!entries.length) {
    +      const empty = document.createElement('div');
    +      empty.className = 'empty';
    +      empty.textContent = 'Click anything in the plan to annotate it, or type below. Feedback goes straight to your agent.';
    +      chatLog.appendChild(empty);
    +      return;
    +    }
    +    for (const entry of entries) {
    +      const div = document.createElement('div');
    +      div.className = 'msg ' + (entry.role === 'agent' ? 'agent' : 'user') + ' kind-' + (entry.kind || 'chat');
    +      div.textContent = entry.text;
    +      const meta = document.createElement('span');
    +      meta.className = 'meta';
    +      meta.textContent = (entry.role === 'agent' ? 'agent' : 'you') + ' \\u00B7 ' + new Date(entry.at).toLocaleTimeString();
    +      div.appendChild(meta);
    +      chatLog.appendChild(div);
    +    }
    +    chatLog.scrollTop = chatLog.scrollHeight;
    +  }
    +  renderChat(boot.chat || []);
    +
    +  // --- send -----------------------------------------------------------
    +  async function send(extraItems) {
    +    if (ended || sending) return;
    +    const items = queue.slice();
    +    if (extraItems) items.push(...extraItems);
    +    const text = input.value.trim();
    +    if (text) items.push({ kind: 'chat', text });
    +    if (!items.length) {
    +      statusEl.textContent = 'Nothing to send yet - annotate the plan or type a message.';
    +      return;
    +    }
    +    sending = true;
    +    sendBtn.disabled = true;
    +    statusEl.textContent = 'Sending\\u2026';
    +    try {
    +      const res = await fetch('/api/session/' + key + '/feedback', {
    +        method: 'POST',
    +        headers: { 'content-type': 'application/json' },
    +        body: JSON.stringify({ items })
    +      });
    +      if (!res.ok) throw new Error('HTTP ' + res.status);
    +      queue = [];
    +      persistQueue();
    +      renderQueue();
    +      input.value = '';
    +      statusEl.textContent = 'Sent. Your agent picks this up on its next check-in.';
    +    } catch (err) {
    +      statusEl.textContent = 'Send failed (' + err.message + ') - is the canvas server still running?';
    +    } finally {
    +      sending = false;
    +      sendBtn.disabled = ended;
    +    }
    +  }
    +  sendBtn.addEventListener('click', () => send());
    +  input.addEventListener('keydown', e => {
    +    if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); }
    +  });
    +  $('approve').addEventListener('click', () => send([{ kind: 'verdict', verdict: 'approve' }]));
    +  $('changes').addEventListener('click', () => send([{ kind: 'verdict', verdict: 'request-changes' }]));
    +
    +  // --- session controls ------------------------------------------------
    +  $('reloadBtn').addEventListener('click', reloadArtifact);
    +  $('endBtn').addEventListener('click', async () => {
    +    if (!window.confirm('End this review session?')) return;
    +    try { await fetch('/api/session/' + key + '/end', { method: 'POST' }); } catch { /* server gone */ }
    +  });
    +  function reloadArtifact() {
    +    const base = frame.getAttribute('data-artifact-src');
    +    frame.src = base + '?t=' + Date.now();
    +  }
    +  function markEnded(endedBy) {
    +    ended = true;
    +    sendBtn.disabled = true;
    +    input.disabled = true;
    +    presence.setAttribute('data-state', 'ended');
    +    presence.querySelector('.label').textContent = 'session ended';
    +    $('endedOverlay').classList.add('show');
    +    $('endedWho').textContent = endedBy === 'agent'
    +      ? 'Your agent closed this review.'
    +      : 'You ended this review. Head back to your agent session.';
    +  }
    +  if (ended) markEnded(boot.endedBy);
    +
    +  // --- server events ----------------------------------------------------
    +  const PRESENCE_LABELS = { waiting: 'agent not connected', listening: 'agent listening', working: 'agent working\\u2026' };
    +  function connectEvents() {
    +    const es = new EventSource('/events/' + key);
    +    es.addEventListener('chat-sync', e => renderChat(JSON.parse(e.data).chat || []));
    +    es.addEventListener('presence', e => {
    +      const state = JSON.parse(e.data).state;
    +      if (ended) return;
    +      presence.setAttribute('data-state', state);
    +      presence.querySelector('.label').textContent = PRESENCE_LABELS[state] || state;
    +    });
    +    es.addEventListener('reload', reloadArtifact);
    +    es.addEventListener('ended', e => { markEnded(JSON.parse(e.data).endedBy); es.close(); });
    +    es.onerror = () => {
    +      if (ended) return;
    +      presence.setAttribute('data-state', 'waiting');
    +      presence.querySelector('.label').textContent = 'canvas server offline';
    +    };
    +  }
    +  connectEvents();
    +})();`;
    +}
    +
    +// The chrome page: header bar, artifact iframe, conversation rail.
    +function renderCanvasHtml(session, { clientPath = '/client.js', cssPath = '/canvas.css' } = {}) {
    +  const name = path.basename(session.file);
    +  const bootstrap = JSON.stringify({
    +    key: session.key,
    +    file: session.file,
    +    status: session.status,
    +    endedBy: session.endedBy || null,
    +    chat: session.chat
    +  }).replace(/
    +
    +
    +
    +
    +${escapeHtml(name)} · Plan Canvas
    +
    +
    +
    +
    +
    +
    +
    + + Plan Canvas + ${escapeHtml(name)} +
    +
    +
    agent not connected
    +
    + Annotate +
    + + + +
    +
    +
    + +

    Session ended

    +
    + +
    + + +`; +} + +// ECC-styled document template for rendered markdown plan artifacts. +function renderMarkdownArtifactHtml(bodyHtml, { title, sdkSrc }) { + const hasMermaid = bodyHtml.includes('class="mermaid"'); + return ` + + + + +${escapeHtml(title)} + + + +
    +${bodyHtml} +
    +${hasMermaid ? mermaidLoaderScript(mermaidUrl()) : ''} + + +`; +} + +// Landing page listing sessions (GET /). +function renderSessionListHtml(sessions) { + const rows = sessions.map(s => { + const status = s.status === 'ended' ? `ended by ${escapeHtml(s.endedBy || 'agent')}` : s.status; + const link = s.status === 'ended' + ? escapeHtml(path.basename(s.file)) + : `${escapeHtml(path.basename(s.file))}`; + return `${link}${escapeHtml(s.file)}${status}`; + }).join('\n'); + return ` + + + +Plan Canvas · sessions + + + +

    Plan Canvas sessions

    +${sessions.length ? `${rows}
    ArtifactPathStatus
    ` : '

    No sessions yet. Ask your agent to open a plan with the plan-canvas skill.

    '} + +`; +} + +module.exports = { + canvasCss, + canvasClientJs, + renderCanvasHtml, + renderMarkdownArtifactHtml, + renderSessionListHtml +}; diff --git a/scripts/plan-canvas.js b/scripts/plan-canvas.js new file mode 100755 index 000000000..3f1206817 --- /dev/null +++ b/scripts/plan-canvas.js @@ -0,0 +1,339 @@ +#!/usr/bin/env node +'use strict'; + +/** + * Plan Canvas CLI — open plan artifacts in a browser review canvas and block + * on human feedback. + * + * node scripts/plan-canvas.js open .claude/plans/feature.plan.md + * node scripts/plan-canvas.js await .claude/plans/feature.plan.md + * node scripts/plan-canvas.js await --reply "Updated section 3." + * node scripts/plan-canvas.js end + * node scripts/plan-canvas.js stop + * + * Agents: `open` returns immediately (the server is a detached process); + * `await` long-polls until the human sends feedback, a verdict, or ends the + * session, then prints a JSON payload to stdout. Progress notes go to stderr + * so stdout stays parseable. + */ + +const fs = require('fs'); +const http = require('http'); +const path = require('path'); +const { spawn } = require('child_process'); + +const { + canonicalizeArtifactPath, + createSessionStore, + resolveStateDir, + sessionKeyFor +} = require('./lib/plan-canvas/sessions'); +const { + DEFAULT_HOST, + createPlanCanvasServer, + resolveIdleTimeoutMs, + resolvePort +} = require('./lib/plan-canvas/server'); + +const VERSION = require('../package.json').version; + +function usage() { + return [ + 'Plan Canvas - review plans and HTML artifacts in the browser', + '', + 'Usage:', + ' node scripts/plan-canvas.js Show server status and sessions', + ' node scripts/plan-canvas.js open Open (or resume) a review session', + ' node scripts/plan-canvas.js await Block until the human sends feedback', + ' node scripts/plan-canvas.js end End a session as the agent', + ' node scripts/plan-canvas.js stop Shut down the canvas server', + ' node scripts/plan-canvas.js server Run the server in the foreground', + '', + 'Options:', + ' open: --no-open Do not launch a browser window', + ' --reopen Reopen a session the user ended from the browser', + ' await: --reply Show an agent reply in the canvas chat before waiting', + ' --timeout-ms Return {status:"waiting"} after n ms (tests/debug only)', + ' server: --port --host ', + '', + 'Environment: ECC_PLAN_CANVAS_PORT, ECC_PLAN_CANVAS_STATE_DIR, ECC_PLAN_CANVAS_IDLE_MS' + ].join('\n'); +} + +function valueAfter(args, name) { + const index = args.indexOf(name); + return index >= 0 && index + 1 < args.length ? args[index + 1] : null; +} + +function serverInfoPath(stateDir) { + return path.join(stateDir, 'server.json'); +} + +function readServerInfo(stateDir) { + try { + return JSON.parse(fs.readFileSync(serverInfoPath(stateDir), 'utf8')); + } catch { + return null; + } +} + +function request(port, method, requestPath, body = null) { + return new Promise((resolve, reject) => { + const payload = body === null ? null : JSON.stringify(body); + const req = http.request( + { + host: DEFAULT_HOST, + port, + method, + path: requestPath, + headers: payload + ? { 'content-type': 'application/json', 'content-length': Buffer.byteLength(payload) } + : {} + }, + res => { + let data = ''; + res.on('data', chunk => { + data += chunk; + }); + res.on('end', () => { + try { + resolve({ statusCode: res.statusCode, body: JSON.parse(data.trim() || '{}') }); + } catch { + resolve({ statusCode: res.statusCode, body: {} }); + } + }); + } + ); + req.on('error', reject); + if (payload) req.write(payload); + req.end(); + }); +} + +async function healthCheck(port) { + try { + const res = await request(port, 'GET', '/health'); + return res.body && res.body.app === 'ecc-plan-canvas' ? res.body : null; + } catch { + return null; + } +} + +function sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +// Start (or reuse) the detached canvas server and return its port. A version +// mismatch after an ECC update restarts the server so browser and CLI never +// disagree about the protocol. +async function ensureServer({ stateDir, port }) { + const health = await healthCheck(port); + if (health && health.version === VERSION) return port; + if (health) { + await request(port, 'POST', '/shutdown').catch(() => {}); + for (let i = 0; i < 20 && (await healthCheck(port)); i++) await sleep(100); + } + fs.mkdirSync(stateDir, { recursive: true }); + const logFd = fs.openSync(path.join(stateDir, 'server.log'), 'a'); + const child = spawn(process.execPath, [__filename, 'server', '--port', String(port)], { + detached: true, + stdio: ['ignore', logFd, logFd], + env: { ...process.env, ECC_PLAN_CANVAS_STATE_DIR: stateDir } + }); + child.unref(); + fs.closeSync(logFd); + for (let i = 0; i < 50; i++) { + await sleep(100); + if (await healthCheck(port)) return port; + } + throw new Error(`plan-canvas server did not become healthy on port ${port}; check ${path.join(stateDir, 'server.log')}`); +} + +function openBrowser(url) { + const platform = process.platform; + const [cmd, args] = + platform === 'darwin' ? ['open', [url]] + : platform === 'win32' ? ['cmd', ['/c', 'start', '', url]] + : ['xdg-open', [url]]; + try { + spawn(cmd, args, { detached: true, stdio: 'ignore' }).unref(); + return true; + } catch { + return false; + } +} + +function output(payload) { + process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`); +} + +async function cmdStatus({ stateDir, port }) { + const health = await healthCheck(port); + if (!health) { + return { server: 'not running', hint: 'open an artifact to start one', stateDir }; + } + const sessions = await request(port, 'GET', '/api/sessions'); + return { server: `http://${DEFAULT_HOST}:${port}`, version: health.version, sessions: sessions.body.sessions }; +} + +async function cmdOpen(file, args, { stateDir, port }) { + if (!file) throw new Error('open requires a file path'); + if (!fs.existsSync(path.resolve(file))) throw new Error(`artifact not found: ${file}`); + await ensureServer({ stateDir, port }); + const res = await request(port, 'POST', '/api/sessions', { + file: path.resolve(file), + reopen: args.includes('--reopen') + }); + if (res.statusCode === 409) return res.body; + if (res.statusCode !== 200) throw new Error(res.body.error || `open failed (HTTP ${res.statusCode})`); + const url = `http://${DEFAULT_HOST}:${port}${res.body.url}`; + const launched = args.includes('--no-open') ? false : openBrowser(url); + return { + status: 'open', + url, + browser: launched ? 'opened' : 'not opened', + next_step: + 'Run `ecc-plan-canvas await ` and leave it running; it returns when the human sends feedback, a verdict, or ends the session.' + }; +} + +function awaitRequest(port, file, timeoutMs) { + const params = new URLSearchParams({ file }); + if (timeoutMs !== null) params.set('timeoutMs', String(timeoutMs)); + return new Promise((resolve, reject) => { + const req = http.request( + { host: DEFAULT_HOST, port, method: 'GET', path: `/api/await?${params}` }, + res => { + let data = ''; + res.on('data', chunk => { + data += chunk; + }); + res.on('end', () => { + try { + resolve(JSON.parse(data.trim())); + } catch { + reject(new Error('await response was not JSON (server restarted?) - re-run await; feedback is never lost')); + } + }); + } + ); + req.setTimeout(0); + req.on('error', reject); + req.end(); + }); +} + +async function cmdAwait(file, args, { stateDir, port }) { + if (!file) throw new Error('await requires a file path'); + if (!(await healthCheck(port))) { + return { status: 'no-server', hint: 'no canvas server is running; use `open` first', stateDir }; + } + const reply = valueAfter(args, '--reply'); + if (reply) { + const key = sessionKeyFor(canonicalizeArtifactPath(file)); + await request(port, 'POST', `/api/session/${key}/reply`, { text: reply }); + } + const timeoutRaw = valueAfter(args, '--timeout-ms'); + const timeoutMs = timeoutRaw === null ? null : Number.parseInt(timeoutRaw, 10) || 0; + process.stderr.write('[plan-canvas] waiting for human feedback... leave this running (re-run if interrupted; queued feedback is never lost)\n'); + const result = await awaitRequest(port, path.resolve(file), timeoutMs); + if (result.status === 'feedback') { + result.next_step = result.sessionEnded + ? 'The user sent this feedback and ended the session. Address it and report in chat; do not reopen the canvas uninvited.' + : 'Address the feedback, then run `ecc-plan-canvas await --reply ""` to answer in the canvas and keep listening.'; + } else if (result.status === 'ended') { + result.next_step = + result.endedBy === 'user' + ? 'The user ended this review. Stop polling and deliver any remaining updates in chat; do not reopen uninvited.' + : 'Session ended. Stop polling.'; + } + return result; +} + +async function cmdEnd(file, { port }) { + if (!file) throw new Error('end requires a file path'); + if (!(await healthCheck(port))) return { status: 'no-server' }; + const res = await request(port, 'POST', '/api/end', { file: path.resolve(file) }); + return res.body; +} + +async function cmdStop({ stateDir, port }) { + if (!(await healthCheck(port))) return { status: 'not running' }; + await request(port, 'POST', '/shutdown').catch(() => {}); + fs.rmSync(serverInfoPath(stateDir), { force: true }); + return { status: 'stopping' }; +} + +async function cmdServer(args, { stateDir, port }) { + const portArg = valueAfter(args, '--port'); + const hostArg = valueAfter(args, '--host'); + const listenPort = portArg !== null ? Number.parseInt(portArg, 10) : port; + const store = createSessionStore({ stateDir }); + let shuttingDown = false; + const shutdown = async code => { + if (shuttingDown) return; + shuttingDown = true; + fs.rmSync(serverInfoPath(stateDir), { force: true }); + await canvas.close().catch(() => {}); + process.exit(code); + }; + const canvas = createPlanCanvasServer({ + store, + host: hostArg || DEFAULT_HOST, + version: VERSION, + idleTimeoutMs: resolveIdleTimeoutMs(), + onIdleShutdown: () => shutdown(0), + log: line => process.stderr.write(`${line}\n`) + }); + const bound = await canvas.listen(listenPort); + fs.mkdirSync(stateDir, { recursive: true }); + fs.writeFileSync( + serverInfoPath(stateDir), + JSON.stringify({ pid: process.pid, port: bound.port, version: VERSION, startedAt: new Date().toISOString() }, null, 2) + ); + // Sessions restored from disk resume their file watchers. + for (const session of store.list()) { + if (session.status !== 'ended') canvas.watchSession(store.get(session.key)); + } + process.on('SIGINT', () => shutdown(0)); + process.on('SIGTERM', () => shutdown(0)); + process.stderr.write(`[plan-canvas] serving on http://${bound.host}:${bound.port}\n`); + return new Promise(() => {}); // run until a signal or idle shutdown +} + +async function main(argv = process.argv.slice(2)) { + const args = argv.slice(); + if (args.includes('--help') || args.includes('-h')) { + process.stdout.write(`${usage()}\n`); + return 0; + } + const command = args[0] && !args[0].startsWith('--') ? args.shift() : null; + const stateDir = resolveStateDir(); + // A running server may sit on a non-default port; trust its recorded info. + const recorded = readServerInfo(stateDir); + const context = { stateDir, port: (recorded && recorded.port) || resolvePort() }; + try { + if (command === null) output(await cmdStatus(context)); + else if (command === 'open') output(await cmdOpen(args[0], args, context)); + else if (command === 'await') output(await cmdAwait(args[0], args, context)); + else if (command === 'end') output(await cmdEnd(args[0], context)); + else if (command === 'stop') output(await cmdStop(context)); + else if (command === 'server') await cmdServer(args, context); + else { + process.stderr.write(`Unknown command: ${command}\n\n${usage()}\n`); + return 1; + } + return 0; + } catch (error) { + output({ error: error.message }); + return 1; + } +} + +if (require.main === module) { + main().then(code => { + process.exitCode = code; + }); +} + +module.exports = { main, ensureServer, healthCheck }; diff --git a/skills/plan-canvas/SKILL.md b/skills/plan-canvas/SKILL.md new file mode 100644 index 000000000..342033fde --- /dev/null +++ b/skills/plan-canvas/SKILL.md @@ -0,0 +1,153 @@ +--- +name: plan-canvas +description: Open plans and HTML artifacts in a local browser canvas where the human annotates elements, chats, and approves or requests changes without leaving the page. Use when presenting a plan for review, or when feedback like "move this, change that" is easier pointed at than typed. +metadata: + origin: ECC +version: "1.0.0" +--- + +# Plan Canvas + +Review loop for plans and visual artifacts: you write the artifact, the human +reviews it in the browser — annotating the exact element they mean, chatting, +and delivering an **Approve plan / Request changes** verdict — while you block +on a single CLI call that returns their feedback as JSON. + +Inspired by [lavish-axi](https://github.com/kunchenguid/lavish-axi); rebuilt +ECC-native around the `/plan` confirmation gate, with zero dependencies. + +## When to Use + +- You just wrote a plan artifact (`.claude/plans/*.plan.md` from `/plan`) and + need the CONFIRM/approve decision — the canvas verdict replaces a typed + "yes/proceed". +- The user should *point at* what to change: reviewing designs, comparisons, + reports, or any local `.md` / `.html` artifact. +- The user asks for `/plan-canvas`, a visual review, or "open it in the browser". + +Do NOT use for: code review of diffs (`/code-review`), running web apps, or +remote URLs. The canvas serves local artifact files only. + +## How It Works + +Invoke the CLI as `ecc-plan-canvas` — the bin shipped by the `ecc-universal` +package (on PATH after a global/plugin install; `node "$CLAUDE_PLUGIN_ROOT/scripts/plan-canvas.js"` +also works for plugin installs). Run it from the project you are reviewing in; +it works from any working directory. It manages a detached loopback server +(`127.0.0.1:4517`) shared by all sessions, keyed by artifact path — no session +ids to track. + +The workflow is a plain CLI-plus-JSON loop, so it is model- and harness-agnostic: +any agent that can run a shell command and read stdout drives it the same way +(Claude Code, Codex, Cursor, Gemini, OpenCode, Copilot). Trigger it however your +harness surfaces skills — e.g. `/plan-canvas` in Claude Code, `$plan-canvas` in +Codex — or just run the `ecc-plan-canvas` commands directly. + +```bash +# 1. Open the artifact in the user's browser (returns immediately) +ecc-plan-canvas open .claude/plans/feature.plan.md + +# 2. Block until the human responds. Leave running; re-run if interrupted — +# queued feedback is never lost. Run in the background if your harness +# time-limits foreground commands. +ecc-plan-canvas await .claude/plans/feature.plan.md +``` + +`await` prints JSON when the human acts: + +```json +{ + "status": "feedback", + "items": [ + { "kind": "annotation", "text": "Split this into two phases", + "anchor": { "selector": "h2:nth-of-type(3)", "tag": "h2", "snippet": "Phase 2: Migration" } }, + { "kind": "verdict", "verdict": "request-changes" } + ] +} +``` + +- `kind: "chat"` — freeform message; answer in the canvas, not the terminal. +- `kind: "annotation"` — feedback anchored to an element (`anchor.selector`, + `anchor.snippet` show what they pointed at; `anchor.textRange.text` when + they highlighted a passage). +- `kind: "verdict"` — `approve` means the plan is CONFIRMED: stop polling, + end the session, and start implementing. `request-changes` means revise the + artifact (the canvas live-reloads it) and keep the loop going. + +**3. Respond in the canvas**, then keep listening — one command does both: + +```bash +ecc-plan-canvas await --reply "Split Phase 2 as requested — take a look." +``` + +**4. End** when review concludes: `ecc-plan-canvas end `. + +## Diagrams (Mermaid) + +When part of the plan is a flow, architecture, sequence, state machine, ER +model, or dependency graph, author it as a fenced ` ```mermaid ` block instead +of ASCII art or a wall of prose — the canvas renders it as a themed diagram the +human can point at. Reach for it when a picture reads faster than a paragraph; +skip it for simple lists or tables. + +````markdown +```mermaid +flowchart LR + A[Market resolves] --> B{Watchers?} + B -->|yes| C[Enqueue jobs] --> D[Fan-out worker] +``` +```` + +Diagrams render in the ECC dark theme with the accent palette. Mermaid loads in +the browser from a pinned CDN; if that is unavailable (offline), the block +degrades to showing its source, so the review is never blocked. Point a local +mirror at `ECC_PLAN_CANVAS_MERMAID_URL` for air-gapped use. + +## Rules + +- Markdown artifacts render in ECC's plan template (including Mermaid blocks); + `.html` artifacts render as-is with the annotation layer injected. For HTML + authoring guidance use the `frontend-design-direction` and `artifact-design` + skills. +- Edit the artifact file to revise — the canvas live-reloads on save. Never + re-run `open` to refresh. +- `{"status": "ended", "endedBy": "user"}` (or `sessionEnded: true` on a + feedback batch) means the user closed the review: stop polling, deliver + remaining updates in chat, and do not reopen. A plain `open` on that + session is refused; pass `--reopen` only when the user asks to resume. +- Sibling assets (images, CSS) must sit next to the artifact and be + referenced by relative path. +- The server is loopback-only and exits after 30 idle minutes + (`ECC_PLAN_CANVAS_IDLE_MS`); `stop` shuts it down explicitly. State lives + in `~/.claude/plan-canvas/` (`ECC_PLAN_CANVAS_STATE_DIR`). + +## Examples + +**Plan approval flow** — `/plan` writes +`.claude/plans/notifications.plan.md` and must WAIT for confirmation: + +```bash +ecc-plan-canvas open .claude/plans/notifications.plan.md +ecc-plan-canvas await .claude/plans/notifications.plan.md +# → {"status":"feedback","items":[{"kind":"verdict","verdict":"approve"}]} +ecc-plan-canvas end .claude/plans/notifications.plan.md +# plan is confirmed — begin implementation +``` + +**Revision loop** — feedback arrives, you edit the file, reply, keep listening: + +```bash +# await returned annotations → edit the .plan.md (canvas live-reloads) +ecc-plan-canvas await --reply "Reworked the risk table." +# → blocks again until the next response +``` + +## Anti-Patterns + +- Polling with `--timeout-ms` in a loop — it exists for tests. Leave the + plain `await` running instead. +- Reopening after a user-initiated end "just to show" something. +- Pasting the whole plan into chat *and* opening a canvas — pick the canvas + and keep the terminal summary to one line. +- Parsing the canvas chat from state files — everything you need arrives via + `await`. diff --git a/tests/hooks/plan-canvas-sessions-hook.test.js b/tests/hooks/plan-canvas-sessions-hook.test.js new file mode 100644 index 000000000..5e8c3ee2d --- /dev/null +++ b/tests/hooks/plan-canvas-sessions-hook.test.js @@ -0,0 +1,100 @@ +/** + * Integration tests for scripts/hooks/plan-canvas-sessions.js (SessionStart) + * + * Run with: node tests/hooks/plan-canvas-sessions-hook.test.js + */ + +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { spawnSync } = require('child_process'); + +const HOOK = path.join(__dirname, '..', '..', 'scripts', 'hooks', 'plan-canvas-sessions.js'); + +function test(name, fn) { + try { + fn(); + console.log(` ✓ ${name}`); + return true; + } catch (err) { + console.log(` ✗ ${name}`); + console.log(` Error: ${err.message}`); + return false; + } +} + +function runHook(stateDir) { + return spawnSync('node', [HOOK], { + encoding: 'utf8', + input: '{}', + env: { ...process.env, ECC_PLAN_CANVAS_STATE_DIR: stateDir } + }); +} + +function writeState(stateDir, sessions) { + fs.mkdirSync(stateDir, { recursive: true }); + fs.writeFileSync(path.join(stateDir, 'sessions.json'), JSON.stringify({ sessions })); +} + +function runTests() { + console.log('\n=== Testing plan-canvas-sessions hook ===\n'); + + let passed = 0; + let failed = 0; + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'plan-canvas-hook-')); + + if (test('exits 0 and prints nothing when no state exists', () => { + const result = runHook(path.join(tmp, 'missing')); + assert.strictEqual(result.status, 0); + assert.strictEqual(result.stdout, ''); + })) passed++; else failed++; + + if (test('exits 0 and prints nothing when all sessions are ended', () => { + const dir = path.join(tmp, 'ended'); + writeState(dir, { + abc123abc123: { key: 'abc123abc123', file: '/x/plan.md', status: 'ended', endedBy: 'user', pendingFeedback: [] } + }); + const result = runHook(dir); + assert.strictEqual(result.status, 0); + assert.strictEqual(result.stdout, ''); + })) passed++; else failed++; + + if (test('surfaces open sessions with resume guidance', () => { + const dir = path.join(tmp, 'open'); + writeState(dir, { + abc123abc123: { + key: 'abc123abc123', + file: '/projects/x/.claude/plans/feature.plan.md', + status: 'feedback', + pendingFeedback: [{ id: 'fb-1' }, { id: 'fb-2' }] + } + }); + const result = runHook(dir); + assert.strictEqual(result.status, 0); + assert.ok(result.stdout.includes('[PlanCanvas]')); + assert.ok(result.stdout.includes('/projects/x/.claude/plans/feature.plan.md')); + assert.ok(result.stdout.includes('2 undelivered feedback items')); + assert.ok(result.stdout.includes('plan-canvas.js await')); + })) passed++; else failed++; + + if (test('exits 0 on corrupt state (never blocks session start)', () => { + const dir = path.join(tmp, 'corrupt'); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'sessions.json'), '{nope'); + const result = runHook(dir); + assert.strictEqual(result.status, 0); + assert.strictEqual(result.stdout, ''); + })) passed++; else failed++; + + fs.rmSync(tmp, { recursive: true, force: true }); + + console.log('\n' + '='.repeat(40)); + console.log(`Passed: ${passed}`); + console.log(`Failed: ${failed}`); + console.log('='.repeat(40)); + + process.exit(failed > 0 ? 1 : 0); +} + +runTests(); diff --git a/tests/integration/plan-canvas-e2e.test.js b/tests/integration/plan-canvas-e2e.test.js new file mode 100644 index 000000000..e4c971a95 --- /dev/null +++ b/tests/integration/plan-canvas-e2e.test.js @@ -0,0 +1,265 @@ +/** + * End-to-end test for Plan Canvas: the complete review workflow through the + * real CLI (scripts/plan-canvas.js) and a real detached server process, with + * the browser side simulated over the same HTTP surface the chrome uses. + * + * Flow under test: + * agent: open --no-open → detached server starts, session opens + * browser: loads canvas + artifact + * agent: await (blocking child) → long poll + * browser: POST annotation + request-changes verdict + * agent: await resolves with feedback JSON + * agent: edits plan, await --reply → reply lands in canvas chat + * browser: POST end → user end is sticky + * agent: open refused / --reopen works / end / stop + * + * Run with: node tests/integration/plan-canvas-e2e.test.js + */ + +const assert = require('assert'); +const fs = require('fs'); +const http = require('http'); +const os = require('os'); +const path = require('path'); +const { spawn, spawnSync } = require('child_process'); + +const CLI = path.join(__dirname, '..', '..', 'scripts', 'plan-canvas.js'); +const HOOK = path.join(__dirname, '..', '..', 'scripts', 'hooks', 'plan-canvas-sessions.js'); + +const results = []; +async function test(name, fn) { + try { + await fn(); + console.log(` ✓ ${name}`); + results.push(true); + } catch (err) { + console.log(` ✗ ${name}`); + console.log(` Error: ${err.stack || err.message}`); + results.push(false); + } +} + +function cli(env, args, { timeoutMs = 15000 } = {}) { + const result = spawnSync('node', [CLI, ...args], { + encoding: 'utf8', + timeout: timeoutMs, + env: { ...process.env, ...env } + }); + let parsed = null; + try { + parsed = JSON.parse(result.stdout.trim()); + } catch { + // leave null; callers assert + } + return { ...result, parsed }; +} + +function request(port, method, requestPath, body = null) { + return new Promise((resolve, reject) => { + const payload = body === null ? null : JSON.stringify(body); + const req = http.request( + { + host: '127.0.0.1', + port, + method, + path: requestPath, + agent: false, + headers: payload ? { 'content-type': 'application/json', 'content-length': Buffer.byteLength(payload) } : {} + }, + res => { + let data = ''; + res.on('data', chunk => { + data += chunk; + }); + res.on('end', () => resolve({ statusCode: res.statusCode, body: data })); + } + ); + req.on('error', reject); + if (payload) req.write(payload); + req.end(); + }); +} + +async function main() { + console.log('\n=== Plan Canvas end-to-end workflow ===\n'); + + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'plan-canvas-e2e-')); + const stateDir = path.join(tmp, 'state'); + const plansDir = path.join(tmp, '.claude', 'plans'); + fs.mkdirSync(plansDir, { recursive: true }); + const plan = path.join(plansDir, 'notifications.plan.md'); + fs.writeFileSync( + plan, + [ + '# Plan: Real-Time Notifications', + '', + '**Complexity**: Medium', + '', + '## Summary', + 'Notify users when watched markets resolve.', + '', + '## Files to Change', + '| File | Action | Why |', + '|---|---|---|', + '| `lib/notify.ts` | CREATE | delivery service |', + '', + '## Tasks', + '### Task 1: Schema', + '- **Action**: add notifications table', + '- **Validate**: `npm test`', + '' + ].join('\n') + ); + + // Unique port so the test never collides with a user's real canvas server. + const port = 20000 + Math.floor(Math.random() * 20000); + const env = { ECC_PLAN_CANVAS_STATE_DIR: stateDir, ECC_PLAN_CANVAS_PORT: String(port) }; + let key = null; + + try { + await test('agent opens the plan: detached server starts, session created', async () => { + const result = cli(env, ['open', plan, '--no-open']); + assert.strictEqual(result.status, 0, result.stderr); + assert.strictEqual(result.parsed.status, 'open'); + assert.ok(result.parsed.url.includes(`127.0.0.1:${port}/canvas/`)); + key = result.parsed.url.split('/canvas/')[1]; + const info = JSON.parse(fs.readFileSync(path.join(stateDir, 'server.json'), 'utf8')); + assert.strictEqual(info.port, port); + }); + + await test('browser loads the canvas chrome and the rendered plan', async () => { + const chrome = await request(port, 'GET', `/canvas/${key}`); + assert.strictEqual(chrome.statusCode, 200); + assert.ok(chrome.body.includes('Plan Canvas')); + assert.ok(chrome.body.includes('notifications.plan.md')); + const doc = await request(port, 'GET', `/artifact/${key}/`); + assert.ok(doc.body.includes('

    ')); + assert.ok(doc.body.includes('lib/notify.ts')); + assert.ok(doc.body.includes('/sdk.js')); + }); + + await test('SessionStart hook surfaces the open review', async () => { + const hook = spawnSync('node', [HOOK], { encoding: 'utf8', input: '{}', env: { ...process.env, ...env } }); + assert.strictEqual(hook.status, 0); + assert.ok(hook.stdout.includes('notifications.plan.md')); + }); + + let awaitChild = null; + let awaitStdout = ''; + const awaitExit = () => + new Promise(resolve => { + awaitChild.on('close', resolve); + }); + + await test('agent blocks on await; user annotation + verdict resolve it', async () => { + awaitChild = spawn('node', [CLI, 'await', plan], { env: { ...process.env, ...env } }); + awaitChild.stdout.on('data', chunk => { + awaitStdout += chunk; + }); + const exited = awaitExit(); + // Queued-then-drained semantics make this race-free: feedback posted + // before the poll attaches is delivered the moment it does. + const post = await request(port, 'POST', `/api/session/${key}/feedback`, { + items: [ + { + kind: 'annotation', + text: 'Also notify via webhook, not just email', + anchor: { selector: 'h3:nth-of-type(1)', tag: 'h3', snippet: 'Task 1: Schema' } + }, + { kind: 'verdict', verdict: 'request-changes' } + ] + }); + assert.strictEqual(post.statusCode, 200); + await exited; + const feedback = JSON.parse(awaitStdout.trim()); + assert.strictEqual(feedback.status, 'feedback'); + assert.strictEqual(feedback.items.length, 2); + assert.strictEqual(feedback.items[0].kind, 'annotation'); + assert.ok(feedback.items[0].anchor.snippet.includes('Task 1')); + assert.strictEqual(feedback.items[1].verdict, 'request-changes'); + assert.ok(feedback.next_step.includes('--reply')); + }); + + await test('agent edits the plan and replies; reply reaches the canvas chat', async () => { + fs.appendFileSync(plan, '\n### Task 2: Webhook channel\n- **Action**: add webhook delivery\n'); + const result = cli(env, ['await', plan, '--reply', 'Added webhook delivery as Task 2.', '--timeout-ms', '400']); + assert.strictEqual(result.status, 0, result.stderr); + assert.strictEqual(result.parsed.status, 'waiting'); + // The chrome bootstraps its chat from the canvas page. + const chrome = await request(port, 'GET', `/canvas/${key}`); + assert.ok(chrome.body.includes('Added webhook delivery as Task 2.')); + const doc = await request(port, 'GET', `/artifact/${key}/`); + assert.ok(doc.body.includes('Webhook channel')); + }); + + await test('user approves; the verdict arrives as plan confirmation', async () => { + awaitChild = spawn('node', [CLI, 'await', plan], { env: { ...process.env, ...env } }); + awaitStdout = ''; + awaitChild.stdout.on('data', chunk => { + awaitStdout += chunk; + }); + const exited = awaitExit(); + await request(port, 'POST', `/api/session/${key}/feedback`, { + items: [{ kind: 'verdict', verdict: 'approve' }] + }); + await exited; + const feedback = JSON.parse(awaitStdout.trim()); + assert.strictEqual(feedback.items[0].verdict, 'approve'); + }); + + await test('user ends the session; plain reopen is refused, --reopen works', async () => { + await request(port, 'POST', `/api/session/${key}/end`); + const refused = cli(env, ['open', plan, '--no-open']); + assert.strictEqual(refused.parsed.status, 'user-ended'); + assert.ok(refused.parsed.next_step.includes('Do not reopen')); + const forced = cli(env, ['open', plan, '--no-open', '--reopen']); + assert.strictEqual(forced.parsed.status, 'open'); + }); + + await test('await on a user-ended session reports ended with guidance', async () => { + await request(port, 'POST', `/api/session/${key}/end`); + const result = cli(env, ['await', plan, '--timeout-ms', '400']); + assert.strictEqual(result.parsed.status, 'ended'); + assert.strictEqual(result.parsed.endedBy, 'user'); + assert.ok(result.parsed.next_step.includes('Stop polling')); + }); + + await test('agent end + status + stop shut everything down', async () => { + cli(env, ['open', plan, '--no-open', '--reopen']); + const ended = cli(env, ['end', plan]); + assert.strictEqual(ended.parsed.endedBy, 'agent'); + const status = cli(env, []); + assert.ok(String(status.parsed.server).includes(`127.0.0.1:${port}`)); + const stop = cli(env, ['stop']); + assert.strictEqual(stop.parsed.status, 'stopping'); + // Server actually exits: health checks fail shortly after. + let gone = false; + for (let i = 0; i < 30 && !gone; i++) { + await new Promise(resolve => setTimeout(resolve, 100)); + gone = await request(port, 'GET', '/health').then(() => false).catch(() => true); + } + assert.ok(gone, 'server should stop listening after stop'); + const after = cli(env, []); + assert.strictEqual(after.parsed.server, 'not running'); + }); + } finally { + // Belt and braces: never leave a server running even if a test failed. + cli(env, ['stop']); + fs.rmSync(tmp, { recursive: true, force: true }); + } + + const passed = results.filter(Boolean).length; + const failed = results.length - passed; + console.log('\n' + '='.repeat(40)); + console.log(`Passed: ${passed}`); + console.log(`Failed: ${failed}`); + console.log('='.repeat(40)); + process.exit(failed > 0 ? 1 : 0); +} + +main().catch(err => { + console.error(err); + console.log('Passed: 0'); + console.log('Failed: 1'); + process.exit(1); +}); diff --git a/tests/lib/loopback-guard.test.js b/tests/lib/loopback-guard.test.js new file mode 100644 index 000000000..61b01dd56 --- /dev/null +++ b/tests/lib/loopback-guard.test.js @@ -0,0 +1,122 @@ +/** + * Tests for scripts/lib/loopback-guard.js + * + * Run with: node tests/lib/loopback-guard.test.js + */ + +const assert = require('assert'); + +const { + LOOPBACK_HOSTNAMES, + buildAllowedHostnames, + isAllowedHostHeader, + isAllowedOrigin, + parseHostHeader +} = require('../../scripts/lib/loopback-guard'); + +function test(name, fn) { + try { + fn(); + console.log(` ✓ ${name}`); + return true; + } catch (err) { + console.log(` ✗ ${name}`); + console.log(` Error: ${err.message}`); + return false; + } +} + +function runTests() { + console.log('\n=== Testing loopback-guard.js ===\n'); + + let passed = 0; + let failed = 0; + + console.log('parseHostHeader:'); + + if (test('strips port from hostname', () => { + assert.strictEqual(parseHostHeader('127.0.0.1:4517'), '127.0.0.1'); + assert.strictEqual(parseHostHeader('localhost:80'), 'localhost'); + })) passed++; else failed++; + + if (test('handles bare hostnames', () => { + assert.strictEqual(parseHostHeader('localhost'), 'localhost'); + })) passed++; else failed++; + + if (test('lowercases hostnames', () => { + assert.strictEqual(parseHostHeader('LocalHost:3000'), 'localhost'); + })) passed++; else failed++; + + if (test('keeps bracketed IPv6 hosts intact', () => { + assert.strictEqual(parseHostHeader('[::1]:4517'), '[::1]'); + })) passed++; else failed++; + + if (test('returns null for missing or malformed values', () => { + assert.strictEqual(parseHostHeader(null), null); + assert.strictEqual(parseHostHeader(undefined), null); + assert.strictEqual(parseHostHeader(''), null); + assert.strictEqual(parseHostHeader(' '), null); + assert.strictEqual(parseHostHeader(42), null); + assert.strictEqual(parseHostHeader('bad:host:extra'), null); + })) passed++; else failed++; + + console.log('\nbuildAllowedHostnames:'); + + if (test('always includes loopback names', () => { + const set = buildAllowedHostnames(null); + for (const name of LOOPBACK_HOSTNAMES) assert.ok(set.has(name)); + })) passed++; else failed++; + + if (test('adds the configured host lowercased', () => { + const set = buildAllowedHostnames('MyBox.Local'); + assert.ok(set.has('mybox.local')); + })) passed++; else failed++; + + console.log('\nisAllowedHostHeader:'); + + const allowed = buildAllowedHostnames('127.0.0.1'); + + if (test('accepts loopback host headers', () => { + assert.strictEqual(isAllowedHostHeader('127.0.0.1:4517', allowed), true); + assert.strictEqual(isAllowedHostHeader('localhost:4517', allowed), true); + assert.strictEqual(isAllowedHostHeader('[::1]:4517', allowed), true); + })) passed++; else failed++; + + if (test('rejects DNS-rebinding style hostnames', () => { + assert.strictEqual(isAllowedHostHeader('evil.example.com', allowed), false); + assert.strictEqual(isAllowedHostHeader('127.0.0.1.evil.example.com', allowed), false); + })) passed++; else failed++; + + if (test('rejects missing host header', () => { + assert.strictEqual(isAllowedHostHeader(undefined, allowed), false); + })) passed++; else failed++; + + console.log('\nisAllowedOrigin:'); + + if (test('absent origin is allowed (same-origin nav, CLI)', () => { + assert.strictEqual(isAllowedOrigin(undefined, allowed), true); + assert.strictEqual(isAllowedOrigin(null, allowed), true); + })) passed++; else failed++; + + if (test('loopback origins are allowed', () => { + assert.strictEqual(isAllowedOrigin('http://127.0.0.1:4517', allowed), true); + assert.strictEqual(isAllowedOrigin('http://localhost:4517', allowed), true); + })) passed++; else failed++; + + if (test('cross-site origins are rejected', () => { + assert.strictEqual(isAllowedOrigin('https://evil.example.com', allowed), false); + })) passed++; else failed++; + + if (test('malformed origins are rejected', () => { + assert.strictEqual(isAllowedOrigin('not a url', allowed), false); + })) passed++; else failed++; + + console.log('\n' + '='.repeat(40)); + console.log(`Passed: ${passed}`); + console.log(`Failed: ${failed}`); + console.log('='.repeat(40)); + + process.exit(failed > 0 ? 1 : 0); +} + +runTests(); diff --git a/tests/lib/plan-canvas-markdown.test.js b/tests/lib/plan-canvas-markdown.test.js new file mode 100644 index 000000000..5b401ff42 --- /dev/null +++ b/tests/lib/plan-canvas-markdown.test.js @@ -0,0 +1,409 @@ +/** + * Tests for scripts/lib/plan-canvas/markdown.js + * + * Run with: node tests/lib/plan-canvas-markdown.test.js + */ + +const assert = require('assert'); + +// Import the module +const { renderMarkdown, escapeHtml, slugify } = require('../../scripts/lib/plan-canvas/markdown'); + +// Test helper +function test(name, fn) { + try { + fn(); + console.log(` ✓ ${name}`); + return true; + } catch (err) { + console.log(` ✗ ${name}`); + console.log(` Error: ${err.message}`); + return false; + } +} + +// Test suite +function runTests() { + console.log('\n=== Testing plan-canvas/markdown.js ===\n'); + + let passed = 0; + let failed = 0; + + // escapeHtml tests + console.log('escapeHtml:'); + + if (test('escapes & < > " \'', () => { + assert.strictEqual( + escapeHtml(''), + '<a href="x" & 'y'>' + ); + })) passed++; else failed++; + + if (test('leaves safe text unchanged', () => { + assert.strictEqual(escapeHtml('plain text 123'), 'plain text 123'); + })) passed++; else failed++; + + if (test('handles null/undefined as empty string', () => { + assert.strictEqual(escapeHtml(null), ''); + assert.strictEqual(escapeHtml(undefined), ''); + })) passed++; else failed++; + + // slugify tests + console.log('\nslugify:'); + + if (test('lowercases and hyphenates spaces', () => { + assert.strictEqual(slugify('Plan Overview'), 'plan-overview'); + })) passed++; else failed++; + + if (test('strips punctuation', () => { + assert.strictEqual(slugify('Files to Change: Phase 1!'), 'files-to-change-phase-1'); + })) passed++; else failed++; + + if (test('collapses repeated separators and trims', () => { + assert.strictEqual(slugify(' A B--C '), 'a-b-c'); + })) passed++; else failed++; + + if (test('returns empty string for symbol-only input', () => { + assert.strictEqual(slugify('***'), ''); + })) passed++; else failed++; + + // Heading tests + console.log('\nHeadings:'); + + for (let level = 1; level <= 6; level++) { + if (test(`renders h${level} with slug id`, () => { + const md = `${'#'.repeat(level)} Title ${level}`; + assert.strictEqual( + renderMarkdown(md), + `Title ${level}` + ); + })) passed++; else failed++; + } + + if (test('heading supports inline formatting, slug ignores markers', () => { + assert.strictEqual( + renderMarkdown('## Rollout **Plan**'), + '

    Rollout Plan

    ' + ); + })) passed++; else failed++; + + // Paragraph and inline tests + console.log('\nParagraphs and Inline:'); + + if (test('splits paragraphs on blank lines', () => { + assert.strictEqual( + renderMarkdown('first para\n\nsecond para'), + '

    first para

    \n

    second para

    ' + ); + })) passed++; else failed++; + + if (test('joins consecutive lines into one paragraph', () => { + assert.strictEqual(renderMarkdown('line a\nline b'), '

    line a\nline b

    '); + })) passed++; else failed++; + + if (test('renders bold, italic, strikethrough, inline code', () => { + const out = renderMarkdown('has **bold**, *ital*, _emph_, ~~gone~~, and `a < b`.'); + assert.strictEqual( + out, + '

    has bold, ital, emph, gone, and a < b.

    ' + ); + })) passed++; else failed++; + + if (test('does not italicize snake_case identifiers', () => { + const out = renderMarkdown('use snake_case_name here'); + assert.ok(!out.includes(''), `No expected, got ${out}`); + })) passed++; else failed++; + + if (test('inline code contents are not parsed further', () => { + assert.strictEqual(renderMarkdown('`**x**`'), '

    **x**

    '); + })) passed++; else failed++; + + // List tests + console.log('\nLists:'); + + if (test('renders nested unordered list (2 levels)', () => { + const out = renderMarkdown('- top one\n - child one\n - child two\n- top two'); + assert.ok(out.startsWith('
      '), 'Should start with
        '); + assert.ok(out.includes('
      • top one\n
          '), 'Nested list should sit inside first
        • '); + assert.ok(out.includes('
        • child one
        • '), 'Should contain first child'); + assert.ok(out.includes('
        \n
      • \n
      • top two
      • '), 'Second top item follows nested list'); + })) passed++; else failed++; + + if (test('renders ordered list', () => { + assert.strictEqual( + renderMarkdown('1. first\n2. second'), + '
          \n
        1. first
        2. \n
        3. second
        4. \n
        ' + ); + })) passed++; else failed++; + + if (test('renders unordered list nested inside ordered list', () => { + const out = renderMarkdown('1. step one\n - detail\n2. step two'); + assert.ok(out.startsWith('
          '), 'Outer list should be
            '); + assert.ok(out.includes('
          1. step one\n
              \n
            • detail
            • \n
            \n
          2. '), `Nested
              expected, got ${out}`); + })) passed++; else failed++; + + if (test('renders task list items (checked and unchecked)', () => { + const out = renderMarkdown('- [ ] draft plan\n- [x] review plan'); + assert.ok(out.includes('
            • draft plan
            • '), `Unchecked task expected, got ${out}`); + assert.ok(out.includes('
            • review plan
            • '), `Checked task expected, got ${out}`); + })) passed++; else failed++; + + if (test('asterisk bullets work like hyphen bullets', () => { + assert.strictEqual(renderMarkdown('* a\n* b'), '
                \n
              • a
              • \n
              • b
              • \n
              '); + })) passed++; else failed++; + + // Table tests + console.log('\nTables:'); + + const planTable = [ + '| File | Action | Why |', + '|:-----|:------:|----:|', + '| `scripts/lib/plan-canvas/markdown.js` | Create | GFM renderer |', + '| `tests/lib/plan-canvas-markdown.test.js` | Create | **Required** coverage |' + ].join('\n'); + + if (test('renders plan-artifact table with thead/tbody', () => { + const out = renderMarkdown(planTable); + assert.ok(out.startsWith(''), 'Should start with
              '); + assert.ok(out.includes(''), 'Should contain '); + assert.ok(out.includes(''), 'Should contain '); + })) passed++; else failed++; + + if (test('applies alignment styles to header and body cells', () => { + const out = renderMarkdown(planTable); + assert.ok(out.includes(''), 'Left-aligned header'); + assert.ok(out.includes(''), 'Center-aligned header'); + assert.ok(out.includes(''), 'Right-aligned header'); + assert.ok(out.includes(''), 'Center-aligned cell'); + })) passed++; else failed++; + + if (test('renders inline code and bold inside table cells', () => { + const out = renderMarkdown(planTable); + assert.ok(out.includes('scripts/lib/plan-canvas/markdown.js'), 'Code span in cell'); + assert.ok(out.includes('Required coverage'), 'Bold in cell'); + })) passed++; else failed++; + + if (test('omits style attribute when column has no alignment', () => { + const out = renderMarkdown('| A | B |\n|---|---|\n| 1 | 2 |'); + assert.ok(out.includes(''), 'Header without style'); + assert.ok(out.includes(''), 'Cell without style'); + assert.ok(!out.includes('style='), 'No style attributes at all'); + })) passed++; else failed++; + + // Code fence tests + console.log('\nFenced Code Blocks:'); + + if (test('renders fence with language class and escaped content', () => { + assert.strictEqual( + renderMarkdown('```js\nconst x = 1 < 2;\n```'), + '
              const x = 1 < 2;
              ' + ); + })) passed++; else failed++; + + if (test('escapes \n```'); + assert.ok(!out.includes(''); + assert.strictEqual(out, '

              <script>alert(1)</script>

              '); + })) passed++; else failed++; + + if (test('javascript: link renders as plain label text', () => { + const out = renderMarkdown('[x](javascript:alert(1))'); + assert.ok(!out.includes(' { + const out = renderMarkdown('[x](JaVaScRiPt:alert(1))'); + assert.ok(!out.includes(' { + const out = renderMarkdown('[x](java\tscript:alert(1))'); + assert.ok(!out.includes(' { + assert.strictEqual(renderMarkdown('[x](data:text/html;base64,AAAA)'), '

              x

              '); + const vb = renderMarkdown('[x](vbscript:msgbox(1))'); + assert.ok(!vb.includes(' { + const out = renderMarkdown('![x](javascript:alert(1))'); + assert.ok(!out.includes(' HTML is escaped', () => { + const out = renderMarkdown(''); + assert.strictEqual(out, '

              <img src=x onerror=alert(1)>

              '); + })) passed++; else failed++; + + if (test('event-handler injection via link text is escaped', () => { + const out = renderMarkdown('[">](https://evil.example)'); + assert.ok(!out.includes(' { + assert.strictEqual( + renderMarkdown('![a"b](x.png)'), + '

              a"b

              ' + ); + })) passed++; else failed++; + + // Link protocol tests + console.log('\nLink Protocols:'); + + if (test('https link gets target=_blank and rel=noopener', () => { + assert.strictEqual( + renderMarkdown('[docs](https://example.com)'), + '

              docs

              ' + ); + })) passed++; else failed++; + + if (test('#anchor link has no target/rel', () => { + assert.strictEqual( + renderMarkdown('[phase](#phase-1)'), + '

              phase

              ' + ); + })) passed++; else failed++; + + if (test('relative link has no target/rel', () => { + assert.strictEqual( + renderMarkdown('[utils](./scripts/lib/utils.js)'), + '

              utils

              ' + ); + })) passed++; else failed++; + + if (test('mailto link allowed without target/rel', () => { + assert.strictEqual( + renderMarkdown('[mail](mailto:team@example.com)'), + '

              mail

              ' + ); + })) passed++; else failed++; + + if (test('relative image src allowed', () => { + assert.strictEqual( + renderMarkdown('![diagram](assets/plan.png)'), + '

              diagram

              ' + ); + })) passed++; else failed++; + + if (test('inline formatting works inside link labels', () => { + const out = renderMarkdown('[`code` and **bold** docs](https://example.com)'); + assert.ok(out.includes('code and bold docs'), `Formatted label expected, got ${out}`); + })) passed++; else failed++; + + // Edge case tests + console.log('\nEdge Cases:'); + + if (test('empty input returns empty string', () => { + assert.strictEqual(renderMarkdown(''), ''); + assert.strictEqual(renderMarkdown(null), ''); + assert.strictEqual(renderMarkdown(undefined), ''); + })) passed++; else failed++; + + if (test('input without trailing newline works', () => { + assert.strictEqual(renderMarkdown('final line'), '

              final line

              '); + })) passed++; else failed++; + + if (test('CRLF line endings are normalized', () => { + assert.strictEqual(renderMarkdown('one\r\n\r\ntwo'), '

              one

              \n

              two

              '); + })) passed++; else failed++; + + if (test('whitespace-only input returns empty string', () => { + assert.strictEqual(renderMarkdown(' \n\n '), ''); + })) passed++; else failed++; + + console.log('\nMermaid diagrams:'); + + if (test('```mermaid becomes
              , not a code block', () => {
              +    const html = renderMarkdown('```mermaid\nflowchart LR\n  A --> B\n```');
              +    assert.ok(html.includes('
              '), 'expected mermaid container');
              +    assert.ok(!html.includes('language-mermaid'), 'should not render as a code block');
              +  })) passed++; else failed++;
              +
              +  if (test('mermaid arrows are entity-escaped so textContent decodes them', () => {
              +    // The browser decodes > back to > in textContent, so the renderer
              +    // still receives valid `-->` while HTML injection is prevented.
              +    const html = renderMarkdown('```mermaid\nA --> B\n```');
              +    assert.ok(html.includes('A --> B'));
              +  })) passed++; else failed++;
              +
              +  if (test('script tags inside a mermaid block are inert', () => {
              +    const html = renderMarkdown('```mermaid\n\n```');
              +    assert.ok(!html.includes(''));
              +    assert.ok(html.includes('<script>'));
              +  })) passed++; else failed++;
              +
              +  if (test('a 
              in the source cannot break out of the container', () => { + const html = renderMarkdown('```mermaid\nA
              \n```'); + assert.ok(!html.includes(' 0 ? 1 : 0); +} + +runTests(); diff --git a/tests/lib/plan-canvas-sessions.test.js b/tests/lib/plan-canvas-sessions.test.js new file mode 100644 index 000000000..ea712a2fa --- /dev/null +++ b/tests/lib/plan-canvas-sessions.test.js @@ -0,0 +1,227 @@ +/** + * Tests for scripts/lib/plan-canvas/sessions.js + * + * Run with: node tests/lib/plan-canvas-sessions.test.js + */ + +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const { + canonicalizeArtifactPath, + createSessionStore, + normalizeFeedbackItem, + sessionKeyFor +} = require('../../scripts/lib/plan-canvas/sessions'); + +function test(name, fn) { + try { + fn(); + console.log(` ✓ ${name}`); + return true; + } catch (err) { + console.log(` ✗ ${name}`); + console.log(` Error: ${err.message}`); + return false; + } +} + +function makeFixture() { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'plan-canvas-test-')); + const artifact = path.join(dir, 'demo.plan.md'); + fs.writeFileSync(artifact, '# Plan\n'); + const store = createSessionStore({ stateDir: path.join(dir, 'state') }); + return { dir, artifact, store }; +} + +function runTests() { + console.log('\n=== Testing plan-canvas sessions.js ===\n'); + + let passed = 0; + let failed = 0; + const fixtures = []; + + console.log('Keys and normalization:'); + + if (test('sessionKeyFor is a stable 12-char hex key', () => { + const key = sessionKeyFor('/tmp/x.md'); + assert.match(key, /^[a-f0-9]{12}$/); + assert.strictEqual(key, sessionKeyFor('/tmp/x.md')); + assert.notStrictEqual(key, sessionKeyFor('/tmp/y.md')); + })) passed++; else failed++; + + if (test('canonicalizeArtifactPath resolves relative paths', () => { + const abs = canonicalizeArtifactPath('some-file.md'); + assert.ok(path.isAbsolute(abs)); + })) passed++; else failed++; + + if (test('normalizeFeedbackItem accepts chat, annotation, verdict', () => { + assert.strictEqual(normalizeFeedbackItem({ kind: 'chat', text: 'hi' }, 1).kind, 'chat'); + const ann = normalizeFeedbackItem( + { kind: 'annotation', text: 'fix', anchor: { selector: 'h2', tag: 'h2', snippet: 'Phase 2' } }, + 2 + ); + assert.strictEqual(ann.anchor.selector, 'h2'); + const verdict = normalizeFeedbackItem({ kind: 'verdict', verdict: 'approve' }, 3); + assert.strictEqual(verdict.verdict, 'approve'); + })) passed++; else failed++; + + if (test('normalizeFeedbackItem rejects malformed input', () => { + assert.strictEqual(normalizeFeedbackItem(null, 1), null); + assert.strictEqual(normalizeFeedbackItem({ kind: 'nope', text: 'x' }, 1), null); + assert.strictEqual(normalizeFeedbackItem({ kind: 'chat', text: '' }, 1), null); + assert.strictEqual(normalizeFeedbackItem({ kind: 'verdict', verdict: 'maybe' }, 1), null); + assert.strictEqual(normalizeFeedbackItem({ kind: 'annotation', text: 'x' }, 1), null); + assert.strictEqual(normalizeFeedbackItem({ kind: 'annotation', text: '', anchor: { selector: 'p' } }, 1), null); + })) passed++; else failed++; + + console.log('\nOpen / reopen semantics:'); + + if (test('open creates a session keyed by canonical path', () => { + const fx = makeFixture(); + fixtures.push(fx); + const { session, refused } = fx.store.open(fx.artifact); + assert.strictEqual(refused, false); + assert.strictEqual(session.status, 'open'); + assert.strictEqual(session.file, canonicalizeArtifactPath(fx.artifact)); + assert.strictEqual(fx.store.findByFile(fx.artifact).key, session.key); + })) passed++; else failed++; + + if (test('user-ended sessions refuse a plain reopen but allow --reopen', () => { + const fx = makeFixture(); + fixtures.push(fx); + const { session } = fx.store.open(fx.artifact); + fx.store.end(session.key, 'user'); + assert.strictEqual(fx.store.open(fx.artifact).refused, true); + const forced = fx.store.open(fx.artifact, { reopen: true }); + assert.strictEqual(forced.refused, false); + assert.strictEqual(forced.session.status, 'open'); + })) passed++; else failed++; + + if (test('agent-ended sessions reopen without a flag', () => { + const fx = makeFixture(); + fixtures.push(fx); + const { session } = fx.store.open(fx.artifact); + fx.store.end(session.key, 'agent'); + assert.strictEqual(fx.store.open(fx.artifact).refused, false); + })) passed++; else failed++; + + console.log('\nFeedback queue / deliver-and-drain:'); + + if (test('queueFeedback filters bad items and mirrors chat', () => { + const fx = makeFixture(); + fixtures.push(fx); + const { session } = fx.store.open(fx.artifact); + const result = fx.store.queueFeedback(session.key, [ + { kind: 'chat', text: 'hello agent' }, + { kind: 'bogus' }, + { kind: 'verdict', verdict: 'approve' } + ]); + assert.strictEqual(result.accepted.length, 2); + assert.strictEqual(result.pending, 2); + const chat = fx.store.get(session.key).chat; + assert.strictEqual(chat.length, 2); + assert.strictEqual(chat[0].role, 'user'); + assert.ok(chat[1].text.includes('Approved the plan')); + })) passed++; else failed++; + + if (test('takeFeedback drains once, then returns waiting', () => { + const fx = makeFixture(); + fixtures.push(fx); + const { session } = fx.store.open(fx.artifact); + fx.store.queueFeedback(session.key, [{ kind: 'chat', text: 'one' }]); + const first = fx.store.takeFeedback(session.key); + assert.strictEqual(first.status, 'feedback'); + assert.strictEqual(first.items.length, 1); + assert.strictEqual(fx.store.takeFeedback(session.key).status, 'waiting'); + })) passed++; else failed++; + + if (test('takeFeedback reports missing for unknown sessions', () => { + const fx = makeFixture(); + fixtures.push(fx); + assert.strictEqual(fx.store.takeFeedback('deadbeef0000').status, 'missing'); + })) passed++; else failed++; + + if (test('send-and-end delivers final batch with attribution', () => { + const fx = makeFixture(); + fixtures.push(fx); + const { session } = fx.store.open(fx.artifact); + fx.store.queueFeedback(session.key, [{ kind: 'chat', text: 'last words' }], { endSession: true }); + const result = fx.store.takeFeedback(session.key); + assert.strictEqual(result.status, 'feedback'); + assert.strictEqual(result.sessionEnded, true); + assert.strictEqual(result.endedBy, 'user'); + const after = fx.store.takeFeedback(session.key); + assert.strictEqual(after.status, 'ended'); + assert.strictEqual(after.endedBy, 'user'); + })) passed++; else failed++; + + if (test('queueFeedback on an ended session is refused', () => { + const fx = makeFixture(); + fixtures.push(fx); + const { session } = fx.store.open(fx.artifact); + fx.store.end(session.key, 'agent'); + assert.strictEqual(fx.store.queueFeedback(session.key, [{ kind: 'chat', text: 'late' }]), null); + })) passed++; else failed++; + + console.log('\nPersistence:'); + + if (test('queued feedback survives a store reload (server restart)', () => { + const fx = makeFixture(); + fixtures.push(fx); + const { session } = fx.store.open(fx.artifact); + fx.store.queueFeedback(session.key, [{ kind: 'chat', text: 'persist me' }]); + const reloaded = createSessionStore({ stateDir: fx.store.stateDir }); + const result = reloaded.takeFeedback(session.key); + assert.strictEqual(result.status, 'feedback'); + assert.strictEqual(result.items[0].text, 'persist me'); + })) passed++; else failed++; + + if (test('corrupt state file starts fresh instead of crashing', () => { + const fx = makeFixture(); + fixtures.push(fx); + fs.mkdirSync(fx.store.stateDir, { recursive: true }); + fs.writeFileSync(fx.store.stateFile, '{not json'); + const reloaded = createSessionStore({ stateDir: fx.store.stateDir }); + assert.deepStrictEqual(reloaded.list(), []); + })) passed++; else failed++; + + if (test('addAgentReply appends to the transcript', () => { + const fx = makeFixture(); + fixtures.push(fx); + const { session } = fx.store.open(fx.artifact); + fx.store.addAgentReply(session.key, 'done, take a look'); + const chat = fx.store.get(session.key).chat; + assert.strictEqual(chat[chat.length - 1].role, 'agent'); + })) passed++; else failed++; + + if (test('list and hasOpenSessions reflect state', () => { + const fx = makeFixture(); + fixtures.push(fx); + assert.strictEqual(fx.store.hasOpenSessions(), false); + const { session } = fx.store.open(fx.artifact); + assert.strictEqual(fx.store.hasOpenSessions(), true); + assert.strictEqual(fx.store.list().length, 1); + fx.store.end(session.key, 'user'); + assert.strictEqual(fx.store.hasOpenSessions(), false); + })) passed++; else failed++; + + for (const fx of fixtures) { + try { + fs.rmSync(fx.dir, { recursive: true, force: true }); + } catch { + // best-effort cleanup + } + } + + console.log('\n' + '='.repeat(40)); + console.log(`Passed: ${passed}`); + console.log(`Failed: ${failed}`); + console.log('='.repeat(40)); + + process.exit(failed > 0 ? 1 : 0); +} + +runTests(); diff --git a/tests/scripts/npm-publish-surface.test.js b/tests/scripts/npm-publish-surface.test.js index 8e33635db..47dc5c08a 100644 --- a/tests/scripts/npm-publish-surface.test.js +++ b/tests/scripts/npm-publish-surface.test.js @@ -58,6 +58,7 @@ function buildExpectedPublishPaths(repoRoot) { "scripts/list-installed.js", "scripts/loop-status.js", "scripts/observability-readiness.js", + "scripts/plan-canvas.js", "scripts/operator-readiness-dashboard.js", "scripts/platform-audit.js", "scripts/preview-pack-smoke.js", diff --git a/tests/scripts/plan-canvas.test.js b/tests/scripts/plan-canvas.test.js new file mode 100644 index 000000000..ebe9107f9 --- /dev/null +++ b/tests/scripts/plan-canvas.test.js @@ -0,0 +1,377 @@ +/** + * Integration tests for the Plan Canvas server (scripts/lib/plan-canvas/). + * + * Spins up the real HTTP server in-process and drives it exactly like the + * browser chrome (fetch + SSE) and the agent CLI (long-poll) do. + * + * Run with: node tests/scripts/plan-canvas.test.js + */ + +const assert = require('assert'); +const fs = require('fs'); +const http = require('http'); +const os = require('os'); +const path = require('path'); + +const { createSessionStore } = require('../../scripts/lib/plan-canvas/sessions'); +const { createPlanCanvasServer } = require('../../scripts/lib/plan-canvas/server'); + +async function test(name, fn) { + try { + await fn(); + console.log(` ✓ ${name}`); + return true; + } catch (err) { + console.log(` ✗ ${name}`); + console.log(` Error: ${err.stack || err.message}`); + return false; + } +} + +function request(port, method, requestPath, { body = null, headers = {} } = {}) { + return new Promise((resolve, reject) => { + const payload = body === null ? null : JSON.stringify(body); + const req = http.request( + { + host: '127.0.0.1', + port, + method, + path: requestPath, + agent: false, + headers: payload + ? { 'content-type': 'application/json', 'content-length': Buffer.byteLength(payload), ...headers } + : headers + }, + res => { + let data = ''; + res.on('data', chunk => { + data += chunk; + }); + res.on('end', () => resolve({ statusCode: res.statusCode, headers: res.headers, body: data })); + } + ); + req.on('error', reject); + if (payload) req.write(payload); + req.end(); + }); +} + +function jsonBody(res) { + return JSON.parse(res.body.trim()); +} + +// Open an SSE stream and collect parsed events into `received`. +function openSse(port, key) { + const received = []; + let close = () => {}; + const ready = new Promise((resolve, reject) => { + const req = http.get( + { host: '127.0.0.1', port, path: `/events/${key}`, agent: false }, + res => { + let buffer = ''; + res.on('data', chunk => { + buffer += chunk; + let idx; + while ((idx = buffer.indexOf('\n\n')) >= 0) { + const frame = buffer.slice(0, idx); + buffer = buffer.slice(idx + 2); + const eventMatch = frame.match(/^event: (.+)$/m); + const dataMatch = frame.match(/^data: (.+)$/m); + if (eventMatch && dataMatch) { + received.push({ event: eventMatch[1], data: JSON.parse(dataMatch[1]) }); + } + } + }); + resolve(); + } + ); + req.on('error', reject); + close = () => req.destroy(); + }); + return { received, ready, close: () => close() }; +} + +function waitFor(predicate, { timeoutMs = 3000, intervalMs = 20 } = {}) { + return new Promise((resolve, reject) => { + const startedAt = Date.now(); + const timer = setInterval(() => { + if (predicate()) { + clearInterval(timer); + resolve(); + } else if (Date.now() - startedAt > timeoutMs) { + clearInterval(timer); + reject(new Error('waitFor timed out')); + } + }, intervalMs); + }); +} + +async function main() { + console.log('\n=== Testing plan-canvas server ===\n'); + + let passed = 0; + let failed = 0; + + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'plan-canvas-server-')); + const artifact = path.join(tmp, 'demo.plan.md'); + fs.writeFileSync(artifact, '# Plan: Demo\n\n## Files to Change\n\n| File | Action |\n|---|---|\n| `a.js` | UPDATE |\n'); + const htmlArtifact = path.join(tmp, 'report.html'); + fs.writeFileSync(htmlArtifact, '

              Report

              '); + fs.writeFileSync(path.join(tmp, 'style.css'), 'body { color: red }'); + fs.writeFileSync(path.join(os.tmpdir(), 'plan-canvas-outside.txt'), 'secret'); + + const store = createSessionStore({ stateDir: path.join(tmp, 'state') }); + let idleFired = false; + const canvas = createPlanCanvasServer({ + store, + version: '9.9.9-test', + heartbeatMs: 25, + idleTimeoutMs: 0, + onIdleShutdown: () => { + idleFired = true; + } + }); + const { port } = await canvas.listen(0); + + let key = null; + let htmlKey = null; + + if (await test('GET /health identifies the app and version', async () => { + const res = await request(port, 'GET', '/health'); + assert.deepStrictEqual(jsonBody(res), { ok: true, app: 'ecc-plan-canvas', version: '9.9.9-test' }); + })) passed++; else failed++; + + if (await test('requests with a non-loopback Host header are rejected', async () => { + const res = await request(port, 'GET', '/health', { headers: { host: 'evil.example.com' } }); + assert.strictEqual(res.statusCode, 403); + })) passed++; else failed++; + + if (await test('requests with a cross-site Origin are rejected', async () => { + const res = await request(port, 'POST', '/shutdown', { headers: { origin: 'https://evil.example.com' } }); + assert.strictEqual(res.statusCode, 403); + })) passed++; else failed++; + + if (await test('POST /api/sessions opens a session for an existing artifact', async () => { + const res = await request(port, 'POST', '/api/sessions', { body: { file: artifact } }); + assert.strictEqual(res.statusCode, 200); + const body = jsonBody(res); + assert.strictEqual(body.status, 'open'); + assert.match(body.key, /^[a-f0-9]{12}$/); + key = body.key; + })) passed++; else failed++; + + if (await test('POST /api/sessions 404s for a missing artifact', async () => { + const res = await request(port, 'POST', '/api/sessions', { body: { file: path.join(tmp, 'nope.md') } }); + assert.strictEqual(res.statusCode, 404); + })) passed++; else failed++; + + if (await test('GET /canvas/:key serves the ECC chrome with CSP', async () => { + const res = await request(port, 'GET', `/canvas/${key}`); + assert.strictEqual(res.statusCode, 200); + assert.ok(res.headers['content-security-policy'].includes("default-src 'self'")); + assert.ok(res.body.includes('Plan Canvas')); + assert.ok(res.body.includes('pc-session')); + assert.ok(res.body.includes('Approve plan')); + assert.ok(res.body.includes('sandbox="allow-scripts allow-forms allow-popups"')); + })) passed++; else failed++; + + if (await test('markdown artifacts render in the ECC plan template with the SDK', async () => { + const res = await request(port, 'GET', `/artifact/${key}/`); + assert.strictEqual(res.statusCode, 200); + assert.ok(res.body.includes('

              ')); + assert.ok(res.body.includes('

              FileActionWhyCreateA1
              ')); + assert.ok(res.body.includes('\n')); + })) passed++; else failed++; + + if (await test('sibling assets are served, traversal is blocked', async () => { + const ok = await request(port, 'GET', `/artifact/${key}/style.css`); + assert.strictEqual(ok.statusCode, 200); + assert.ok(ok.body.includes('color: red')); + const escape = await request(port, 'GET', `/artifact/${key}/..%2Fplan-canvas-outside.txt`); + assert.strictEqual(escape.statusCode, 403); + })) passed++; else failed++; + + if (await test('static chrome assets are served', async () => { + for (const asset of ['/canvas.css', '/client.js', '/sdk.js']) { + const res = await request(port, 'GET', asset); + assert.strictEqual(res.statusCode, 200, `${asset} should be 200`); + } + })) passed++; else failed++; + + if (await test('await with timeoutMs returns waiting when idle', async () => { + const res = await request(port, 'GET', `/api/await?file=${encodeURIComponent(artifact)}&timeoutMs=50`); + assert.strictEqual(jsonBody(res).status, 'waiting'); + })) passed++; else failed++; + + if (await test('await returns missing for files without a session', async () => { + const res = await request(port, 'GET', `/api/await?file=${encodeURIComponent(path.join(tmp, 'other.md'))}`); + assert.strictEqual(jsonBody(res).status, 'missing'); + })) passed++; else failed++; + + if (await test('browser feedback wakes a blocking await; presence transitions', async () => { + const sse = openSse(port, key); + await sse.ready; + const awaitPromise = request(port, 'GET', `/api/await?file=${encodeURIComponent(artifact)}`); + await waitFor(() => sse.received.some(e => e.event === 'presence' && e.data.state === 'listening')); + + const post = await request(port, 'POST', `/api/session/${key}/feedback`, { + body: { + items: [ + { kind: 'annotation', text: 'tighten this', anchor: { selector: 'h2:nth-of-type(1)', tag: 'h2', snippet: 'Files to Change' } }, + { kind: 'verdict', verdict: 'request-changes' } + ] + } + }); + assert.strictEqual(jsonBody(post).accepted, 2); + + const result = jsonBody(await awaitPromise); + assert.strictEqual(result.status, 'feedback'); + assert.strictEqual(result.items.length, 2); + assert.strictEqual(result.items[0].anchor.selector, 'h2:nth-of-type(1)'); + assert.strictEqual(result.items[1].verdict, 'request-changes'); + + await waitFor(() => sse.received.some(e => e.event === 'presence' && e.data.state === 'working')); + await waitFor(() => sse.received.some(e => e.event === 'chat-sync' && e.data.chat.length === 2)); + sse.close(); + })) passed++; else failed++; + + if (await test('long-poll heartbeat whitespace arrives before the payload', async () => { + const chunks = []; + const done = new Promise((resolve, reject) => { + const req = http.get( + { host: '127.0.0.1', port, path: `/api/await?file=${encodeURIComponent(artifact)}`, agent: false }, + res => { + res.on('data', chunk => chunks.push(chunk.toString())); + res.on('end', resolve); + } + ); + req.on('error', reject); + }); + // Heartbeats tick every 25ms in this test server; wait for a few first. + await waitFor(() => chunks.join('').length >= 3); + assert.ok(/^\s+$/.test(chunks.join('')), 'expected only whitespace before payload'); + await request(port, 'POST', `/api/session/${key}/feedback`, { body: { items: [{ kind: 'chat', text: 'wake up' }] } }); + await done; + const full = chunks.join(''); + assert.strictEqual(JSON.parse(full.trim()).status, 'feedback'); + })) passed++; else failed++; + + if (await test('agent reply lands in the chat via SSE chat-sync', async () => { + const sse = openSse(port, key); + await sse.ready; + const res = await request(port, 'POST', `/api/session/${key}/reply`, { body: { text: 'reworked, please re-check' } }); + assert.strictEqual(jsonBody(res).status, 'sent'); + await waitFor(() => + sse.received.some( + e => e.event === 'chat-sync' && e.data.chat.some(m => m.role === 'agent' && m.text.includes('reworked')) + ) + ); + sse.close(); + })) passed++; else failed++; + + if (await test('live reload: editing the artifact emits an SSE reload event', async () => { + const sse = openSse(port, key); + await sse.ready; + fs.appendFileSync(artifact, '\n## Addendum\n'); + await waitFor(() => sse.received.some(e => e.event === 'reload'), { timeoutMs: 4000 }); + sse.close(); + })) passed++; else failed++; + + if (await test('send-and-end delivers the final batch and ends the session', async () => { + const awaitPromise = request(port, 'GET', `/api/await?file=${encodeURIComponent(artifact)}`); + await waitFor(() => canvas.presenceFor(key) === 'listening'); + await request(port, 'POST', `/api/session/${key}/feedback`, { + body: { items: [{ kind: 'chat', text: 'looks good, wrapping up' }], endSession: true } + }); + const result = jsonBody(await awaitPromise); + assert.strictEqual(result.status, 'feedback'); + assert.strictEqual(result.sessionEnded, true); + assert.strictEqual(result.endedBy, 'user'); + const after = await request(port, 'GET', `/api/await?file=${encodeURIComponent(artifact)}&timeoutMs=0`); + assert.strictEqual(jsonBody(after).status, 'ended'); + })) passed++; else failed++; + + if (await test('user-ended sessions return 409 on plain reopen, open with reopen:true', async () => { + const refused = await request(port, 'POST', '/api/sessions', { body: { file: artifact } }); + assert.strictEqual(refused.statusCode, 409); + assert.strictEqual(jsonBody(refused).status, 'user-ended'); + const forced = await request(port, 'POST', '/api/sessions', { body: { file: artifact, reopen: true } }); + assert.strictEqual(forced.statusCode, 200); + })) passed++; else failed++; + + if (await test('agent end via POST /api/end allows plain reopen', async () => { + const res = await request(port, 'POST', '/api/end', { body: { file: artifact } }); + assert.strictEqual(jsonBody(res).endedBy, 'agent'); + const reopened = await request(port, 'POST', '/api/sessions', { body: { file: artifact } }); + assert.strictEqual(reopened.statusCode, 200); + })) passed++; else failed++; + + if (await test('feedback on an ended session is refused with 409', async () => { + await request(port, 'POST', `/api/end`, { body: { file: htmlArtifact } }); + const res = await request(port, 'POST', `/api/session/${htmlKey}/feedback`, { + body: { items: [{ kind: 'chat', text: 'too late' }] } + }); + assert.strictEqual(res.statusCode, 409); + })) passed++; else failed++; + + if (await test('GET / lists sessions in the ECC shell', async () => { + const res = await request(port, 'GET', '/'); + assert.ok(res.body.includes('Plan Canvas sessions')); + assert.ok(res.body.includes('demo.plan.md')); + })) passed++; else failed++; + + if (await test('POST /shutdown triggers the shutdown callback', async () => { + const res = await request(port, 'POST', '/shutdown'); + assert.strictEqual(jsonBody(res).status, 'stopping'); + await waitFor(() => idleFired); + })) passed++; else failed++; + + if (await test('close() settles a held long-poll instead of hanging', async () => { + await request(port, 'POST', '/api/sessions', { body: { file: artifact, reopen: true } }); + const held = request(port, 'GET', `/api/await?file=${encodeURIComponent(artifact)}`); + await waitFor(() => canvas.presenceFor(store.findByFile(artifact).key) === 'listening'); + await canvas.close(); + const result = jsonBody(await held); + assert.strictEqual(result.status, 'waiting'); + assert.ok(result.note.includes('shutting down')); + })) passed++; else failed++; + + fs.rmSync(tmp, { recursive: true, force: true }); + fs.rmSync(path.join(os.tmpdir(), 'plan-canvas-outside.txt'), { force: true }); + + console.log('\n' + '='.repeat(40)); + console.log(`Passed: ${passed}`); + console.log(`Failed: ${failed}`); + console.log('='.repeat(40)); + + process.exit(failed > 0 ? 1 : 0); +} + +main().catch(err => { + console.error(err); + console.log('Passed: 0'); + console.log('Failed: 1'); + process.exit(1); +}); diff --git a/yarn.lock b/yarn.lock index fa21fac6e..dc33ebbac 100644 --- a/yarn.lock +++ b/yarn.lock @@ -593,6 +593,7 @@ __metadata: ecc: scripts/ecc.js ecc-control-pane: scripts/control-pane.js ecc-install: scripts/install-apply.js + ecc-plan-canvas: scripts/plan-canvas.js languageName: unknown linkType: soft