mirror of
https://github.com/affaan-m/ECC.git
synced 2026-08-17 21:15:40 +02:00
feat: Plan Canvas, a browser review canvas for plans (#2467)
* feat: add Plan Canvas - browser annotate-and-approve review for plan artifacts - scripts/plan-canvas.js CLI (open/await/end/stop/server; bin ecc-plan-canvas) - loopback server + ECC-styled chrome + annotation SDK + zero-dep markdown renderer - Approve/Request-changes verdicts wired to the /plan confirmation gate - plan-canvas skill, /plan-canvas command, SessionStart hook surfacing open reviews - shared scripts/lib/loopback-guard.js extracted from control-pane (API re-exported) - 121 new tests incl. full-workflow E2E; registered in manifests, catalog, registry Inspired by lavish-axi (https://github.com/kunchenguid/lavish-axi) by @kunchenguid; original ECC-native implementation, not a port. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(plan-canvas): invoke via ecc-plan-canvas bin so the skill works from any project Skill/command referenced a cwd-relative `node scripts/plan-canvas.js`, unusable outside the ECC root. Switch to the ecc-plan-canvas bin (and $CLAUDE_PLUGIN_ROOT fallback) and align CLI next_step hints so an agent can run it as a skill in any repo. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(plan-canvas): render Mermaid diagrams + ship Codex cross-harness surface - markdown renderer emits <pre class="mermaid"> for ```mermaid blocks (source entity-escaped so the browser decodes it for the renderer while blocking injection) - artifact template loads a pinned Mermaid build only when a diagram is present, themed to ECC dark, securityLevel strict, graceful offline fallback to source (ECC_PLAN_CANVAS_MERMAID_URL overrides for a local mirror) - skill teaches Mermaid-for-diagrams and states the CLI+JSON loop is harness-agnostic - add .agents/skills/plan-canvas (Codex) with agents/openai.yaml interface manifest - register in install-modules workflow-quality paths; docs updated Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(plan-canvas): add demo screenshot Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ci): sync yarn.lock with new bin; add contributor checklist - yarn.lock records the ecc-plan-canvas bin so Yarn hardened-mode install no longer wants to modify the lockfile on public PRs - PR template + CONTRIBUTING gain a pre-push checklist covering the lockfile trap and the full skill/command/CLI registration surfaces Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Haley Chen <2022hachen@gmail.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
Haley Chen
parent
4130457d67
commit
a511395613
@@ -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 <file> --reply "Split Phase 2 as requested — take a look."
|
||||
```
|
||||
|
||||
**4. End** when review concludes: `ecc-plan-canvas end <file>`.
|
||||
|
||||
## 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 <file> --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`.
|
||||
@@ -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
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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/<name>/` 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
+13
-1
@@ -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/<name>/` 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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
+1
-1
@@ -164,7 +164,7 @@ Copy-Item -Recurse rules/typescript "$HOME/.claude/rules/"
|
||||
/plugin list ecc@ecc
|
||||
```
|
||||
|
||||
**完成!** 你现在可以使用 67 个代理、277 个技能和 93 个命令。
|
||||
**完成!** 你现在可以使用 67 个代理、278 个技能和 94 个命令。
|
||||
|
||||
### multi-* 命令需要额外配置
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 <artifact>` — opens the user's browser.
|
||||
3. `ecc-plan-canvas await <artifact>` — block until feedback,
|
||||
verdict, or session end; leave it running.
|
||||
4. Apply feedback to the artifact file (the canvas live-reloads), answer with
|
||||
`await <artifact> --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/`
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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": [
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
@@ -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.
|
||||
|
||||

|
||||
|
||||
## 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 <artifact>` 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 <artifact>` (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/<key>` — editor chrome; `GET /artifact/<key>/` — rendered artifact
|
||||
(markdown → ECC plan template, HTML passthrough) with the annotation SDK injected;
|
||||
sibling assets confined to the artifact directory
|
||||
- `POST /api/session/<key>/feedback` `{items[], endSession?}` — browser queues
|
||||
chat / annotation / verdict items
|
||||
- `GET /api/await?file=<path>[&timeoutMs=n]` — agent long-poll (whitespace heartbeat);
|
||||
returns `{status: feedback|ended|waiting|missing, items[], sessionEnded?, endedBy?}`
|
||||
- `POST /api/session/<key>/reply` `{text}` — agent message → canvas chat
|
||||
- `POST /api/session/<key>/end` (user) / `POST /api/end` `{file}` (agent) — ender recorded;
|
||||
user ends are sticky: plain `open` refuses to reopen without `--reopen`
|
||||
- `GET /events/<key>` — 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.
|
||||
@@ -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 实用工具
|
||||
|
||||
@@ -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 条指令 |
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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<s.length;i++){r=L(p.join(d,'plugins',s[i]));if(r)return r}try{var g=['ecc','everything-claude-code'];for(var j=0;j<g.length;j++){var c=p.join(d,'plugins','cache',g[j]);var O=f.readdirSync(c);for(var k=0;k<O.length;k++){var q=p.join(c,O[k]);var V=f.readdirSync(q);for(var m=0;m<V.length;m++){r=L(p.join(q,V[m]));if(r)return r}}}}catch(_){}return d})();const s=p.join(r,'scripts/hooks/plugin-hook-bootstrap.js');process.env.CLAUDE_PLUGIN_ROOT=r;process.argv.splice(1,0,s);require(s)\" node scripts/hooks/run-with-flags.js session-start:plan-canvas-sessions scripts/hooks/plan-canvas-sessions.js standard,strict"
|
||||
}
|
||||
],
|
||||
"description": "Surface open Plan Canvas review sessions so a fresh session can resume the loop",
|
||||
"id": "session-start:plan-canvas-sessions"
|
||||
}
|
||||
],
|
||||
"PostToolUse": [
|
||||
|
||||
@@ -454,6 +454,14 @@
|
||||
"agents-core"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "skill:plan-canvas",
|
||||
"family": "skill",
|
||||
"description": "Browser review canvas for plan artifacts: annotate, chat, approve or request changes.",
|
||||
"modules": [
|
||||
"workflow-quality"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "skill:tdd-workflow",
|
||||
"family": "skill",
|
||||
|
||||
@@ -272,6 +272,7 @@
|
||||
"skills/eval-harness",
|
||||
"skills/hookify-rules",
|
||||
"skills/iterative-retrieval",
|
||||
"skills/plan-canvas",
|
||||
"skills/plankton-code-quality",
|
||||
"skills/production-audit",
|
||||
"skills/skill-scout",
|
||||
|
||||
+4
-1
@@ -107,6 +107,7 @@
|
||||
"scripts/lib/",
|
||||
"scripts/list-installed.js",
|
||||
"scripts/loop-status.js",
|
||||
"scripts/plan-canvas.js",
|
||||
"scripts/orchestration-status.js",
|
||||
"scripts/orchestrate-codex-worker.sh",
|
||||
"scripts/orchestrate-worktrees.js",
|
||||
@@ -249,6 +250,7 @@
|
||||
"skills/perl-patterns/",
|
||||
"skills/perl-security/",
|
||||
"skills/perl-testing/",
|
||||
"skills/plan-canvas/",
|
||||
"skills/plankton-code-quality/",
|
||||
"skills/parallel-execution-optimizer/",
|
||||
"skills/postgres-patterns/",
|
||||
@@ -326,7 +328,8 @@
|
||||
"bin": {
|
||||
"ecc": "scripts/ecc.js",
|
||||
"ecc-control-pane": "scripts/control-pane.js",
|
||||
"ecc-install": "scripts/install-apply.js"
|
||||
"ecc-install": "scripts/install-apply.js",
|
||||
"ecc-plan-canvas": "scripts/plan-canvas.js"
|
||||
},
|
||||
"scripts": {
|
||||
"postinstall": "echo '\\n ecc-universal installed!\\n Run: npx ecc typescript\\n Compat: npx ecc-install typescript\\n Docs: https://github.com/affaan-m/ECC\\n'",
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Plan Canvas open-session surfacing (SessionStart)
|
||||
*
|
||||
* Cross-platform (Windows, macOS, Linux)
|
||||
*
|
||||
* If a Plan Canvas review is still open from a previous agent session,
|
||||
* surface it at session start so a fresh session can resume the loop with
|
||||
* `plan-canvas await <file>` 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 <file>` (plan-canvas skill), or `end <file>` 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 };
|
||||
@@ -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 [
|
||||
|
||||
@@ -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
|
||||
};
|
||||
@@ -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, '"')
|
||||
.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, '<strong>$1</strong>')
|
||||
.replace(/~~([^~]+)~~/g, '<del>$1</del>')
|
||||
.replace(/\*([^*]+)\*/g, '<em>$1</em>')
|
||||
.replace(/(^|[^\w])_([^_]+)_(?!\w)/g, '$1<em>$2</em>');
|
||||
}
|
||||
|
||||
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>' + code + '</code>'));
|
||||
|
||||
s = s.replace(/!\[([^\]]*)\]\(([^)]*)\)/g, (_m, alt, src) => {
|
||||
const kind = classifyUrl(src);
|
||||
if (kind !== 'http' && kind !== 'relative') return alt;
|
||||
return stash('<img src="' + src.trim() + '" alt="' + 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('<a href="' + url.trim() + '"' + extra + '>' + text + '</a>');
|
||||
});
|
||||
|
||||
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 '<li class="task"><input type="checkbox" disabled' + checked + '> ' +
|
||||
renderInline(task[2]) + '</li>';
|
||||
}
|
||||
return '<li>' + renderInline(text) + '</li>';
|
||||
}
|
||||
|
||||
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 <li>
|
||||
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</li>'));
|
||||
} else {
|
||||
parts.push('<li>\n' + nested.html + '\n</li>');
|
||||
}
|
||||
i = nested.end;
|
||||
} else {
|
||||
parts.push(renderListItem(items[i].text));
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
return { html: '<' + tag + '>\n' + parts.join('\n') + '\n</' + tag + '>', 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 </pre> breakout.
|
||||
out.push('<pre class="mermaid">' + escapeHtml(body.join('\n')) + '</pre>');
|
||||
continue;
|
||||
}
|
||||
const cls = lang ? ' class="language-' + lang + '"' : '';
|
||||
out.push('<pre><code' + cls + '>' + escapeHtml(body.join('\n')) + '</code></pre>');
|
||||
continue;
|
||||
}
|
||||
|
||||
const heading = line.match(/^(#{1,6})\s+(.+?)\s*$/);
|
||||
if (heading) {
|
||||
const level = heading[1].length;
|
||||
out.push('<h' + level + ' id="' + slugify(heading[2]) + '">' +
|
||||
renderInline(heading[2]) + '</h' + level + '>');
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Horizontal rule (alignment rows never reach here: tables consume them)
|
||||
if (HR_RE.test(line)) {
|
||||
out.push('<hr>');
|
||||
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('<blockquote>\n' + renderMarkdown(inner.join('\n')) + '\n</blockquote>');
|
||||
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) => '<tr>' + cells.map((cell, idx) => {
|
||||
const style = aligns[idx] ? ' style="text-align:' + aligns[idx] + '"' : '';
|
||||
return '<' + tag + style + '>' + renderInline(cell) + '</' + tag + '>';
|
||||
}).join('') + '</tr>';
|
||||
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('<table>\n<thead>\n' + head + '\n</thead>\n<tbody>\n' +
|
||||
body.join('\n') + '\n</tbody>\n</table>');
|
||||
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('<p>' + renderInline(para.join('\n')) + '</p>');
|
||||
}
|
||||
|
||||
return out.join('\n');
|
||||
}
|
||||
|
||||
module.exports = { renderMarkdown, escapeHtml, slugify };
|
||||
@@ -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 = \`
|
||||
<style>
|
||||
:host{all:initial}
|
||||
.hl{position:fixed;pointer-events:none;border:1.5px solid #6885e8;background:rgba(104,133,232,0.12);border-radius:4px;display:none;z-index:2147483646;transition:all .06s ease-out}
|
||||
.selhint{position:absolute;display:none;z-index:2147483647;background:#101218;color:#dfe2e9;border:1px solid #272c3e;border-radius:6px;padding:4px 10px;font:600 11.5px -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;cursor:pointer;box-shadow:0 8px 32px rgba(0,0,0,0.6)}
|
||||
.selhint:hover{border-color:#6885e8}
|
||||
.card{position:absolute;display:none;z-index:2147483647;width:300px;background:#101218;border:1px solid #272c3e;border-radius:8px;box-shadow:0 8px 32px rgba(0,0,0,0.6);font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;color:#dfe2e9}
|
||||
.card h4{margin:0;padding:10px 12px 0;font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.05em;color:#80859a}
|
||||
.card .snippet{padding:4px 12px 0;font:10.5px 'SF Mono','Fira Code',monospace;color:#4acbbe;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.card textarea{display:block;width:calc(100% - 24px);margin:8px 12px;min-height:56px;resize:vertical;background:#13161e;border:1px solid #1d2130;border-radius:6px;color:#dfe2e9;font:12.5px -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;padding:7px 9px;outline:none;box-sizing:border-box}
|
||||
.card textarea:focus{border-color:#6885e8}
|
||||
.card .row{display:flex;justify-content:flex-end;gap:8px;padding:0 12px 12px}
|
||||
.card button{border-radius:6px;font:600 11.5px -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;padding:5px 12px;cursor:pointer}
|
||||
.card .cancel{background:none;border:1px solid #1d2130;color:#80859a}
|
||||
.card .cancel:hover{color:#dfe2e9;border-color:#272c3e}
|
||||
.card .queue{background:#6885e8;border:1px solid #6885e8;color:#fff}
|
||||
.card .queue:hover{background:#3d5ab8}
|
||||
.card .keys{padding:0 12px 10px;font-size:9.5px;color:#4c5168}
|
||||
</style>
|
||||
<div class="hl"></div>
|
||||
<button class="selhint" type="button">Annotate selection</button>
|
||||
<div class="card">
|
||||
<h4></h4>
|
||||
<div class="snippet"></div>
|
||||
<textarea placeholder="What should change here?"></textarea>
|
||||
<div class="row">
|
||||
<button class="cancel" type="button">Cancel</button>
|
||||
<button class="queue" type="button">Queue</button>
|
||||
</div>
|
||||
<div class="keys">Enter to queue · Cmd/Ctrl+Enter to queue & send</div>
|
||||
</div>\`;
|
||||
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 };
|
||||
@@ -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<res>
|
||||
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, '<h1>Unknown session</h1>');
|
||||
|
||||
if (!assetPath) {
|
||||
let content;
|
||||
try {
|
||||
content = fs.readFileSync(session.file, 'utf8');
|
||||
} catch {
|
||||
return sendHtml(res, 404, `<h1>Artifact missing</h1><p>${session.file} no longer exists.</p>`, { 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 = '<script src="/sdk.js"></script>';
|
||||
const injected = content.includes('</body>')
|
||||
? content.replace('</body>', `${sdkTag}\n</body>`)
|
||||
: `${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, '<h1>Unknown session</h1>');
|
||||
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
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
@@ -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 `<pre class="mermaid">` blocks, themed to match
|
||||
// the ECC canvas. Kept import-only so a CDN failure degrades gracefully.
|
||||
function mermaidLoaderScript(url) {
|
||||
return `<script type="module">
|
||||
try {
|
||||
const mermaid = (await import(${JSON.stringify(url)})).default;
|
||||
mermaid.initialize({
|
||||
startOnLoad: false,
|
||||
securityLevel: 'strict',
|
||||
theme: 'dark',
|
||||
fontFamily: "-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif",
|
||||
themeVariables: {
|
||||
primaryColor: '#13161e', primaryBorderColor: '#6885e8', primaryTextColor: '#dfe2e9',
|
||||
lineColor: '#80859a', secondaryColor: '#191d2a', tertiaryColor: '#101218',
|
||||
background: '#080a0e', mainBkg: '#13161e', clusterBkg: '#0d0f14'
|
||||
}
|
||||
});
|
||||
await mermaid.run({ querySelector: '.mermaid' });
|
||||
} catch (err) {
|
||||
document.querySelectorAll('.mermaid').forEach(el => el.classList.add('mermaid-unrendered'));
|
||||
console.warn('Mermaid render skipped:', err && err.message);
|
||||
}
|
||||
</script>`;
|
||||
}
|
||||
|
||||
// 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(/</g, '\\u003c');
|
||||
const artifactSrc = `/artifact/${session.key}/`;
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>${escapeHtml(name)} · Plan Canvas</title>
|
||||
<link rel="stylesheet" href="${cssPath}">
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><defs><linearGradient id='g' x1='0' y1='0' x2='1' y2='1'><stop offset='0' stop-color='%236885e8'/><stop offset='1' stop-color='%23e26a9e'/></linearGradient></defs><rect width='100' height='100' rx='22' fill='url(%23g)'/><text x='50' y='68' font-size='52' font-weight='700' font-family='sans-serif' fill='white' text-anchor='middle'>E</text></svg>">
|
||||
</head>
|
||||
<body>
|
||||
<script id="pc-session" type="application/json">${bootstrap}</script>
|
||||
<header class="bar">
|
||||
<div class="brand">
|
||||
<div class="logo">E</div>
|
||||
<span class="name">Plan Canvas</span>
|
||||
<span class="file" title="${escapeHtml(session.file)}">${escapeHtml(name)}</span>
|
||||
</div>
|
||||
<div class="spacer"></div>
|
||||
<div id="presence" class="presence" data-state="waiting"><span class="dot"></span><span class="label">agent not connected</span></div>
|
||||
<div id="annotate" class="toggle" role="switch" aria-pressed="true" title="Toggle annotate mode (Cmd/Ctrl+I)">
|
||||
<span>Annotate</span><span class="track"><span class="knob"></span></span>
|
||||
</div>
|
||||
<button id="themeBtn" class="icon-btn" type="button">light</button>
|
||||
<button id="reloadBtn" class="icon-btn" type="button" title="Reload artifact">Reload</button>
|
||||
<button id="endBtn" class="icon-btn danger" type="button">End session</button>
|
||||
</header>
|
||||
<div class="layout">
|
||||
<main class="frame">
|
||||
<iframe id="artifact" title="Artifact under review" src="${artifactSrc}" data-artifact-src="${artifactSrc}" sandbox="allow-scripts allow-forms allow-popups"></iframe>
|
||||
<div id="endedOverlay" class="overlay"><div class="card"><h3>Session ended</h3><p id="endedWho"></p></div></div>
|
||||
</main>
|
||||
<aside class="panel">
|
||||
<h2>Plan verdict</h2>
|
||||
<div class="verdict">
|
||||
<button id="approve" class="approve" type="button">Approve plan</button>
|
||||
<button id="changes" class="changes" type="button">Request changes</button>
|
||||
</div>
|
||||
<h2>Conversation</h2>
|
||||
<div id="chatLog" class="chat"></div>
|
||||
<div id="queue" class="queue"></div>
|
||||
<div class="composer">
|
||||
<textarea id="chatInput" placeholder="Message your agent Enter to send · Shift+Enter for a new line"></textarea>
|
||||
<div class="row">
|
||||
<button id="send" class="send" type="button">Send to agent</button>
|
||||
</div>
|
||||
<div id="sendStatus" class="status"></div>
|
||||
<div class="hint">Annotations queue up here until you send them together.</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
<script src="${clientPath}"></script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
// ECC-styled document template for rendered markdown plan artifacts.
|
||||
function renderMarkdownArtifactHtml(bodyHtml, { title, sdkSrc }) {
|
||||
const hasMermaid = bodyHtml.includes('class="mermaid"');
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>${escapeHtml(title)}</title>
|
||||
<style>
|
||||
${TOKENS_CSS}
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{font-family:var(--font);background:var(--bg);color:var(--text);-webkit-font-smoothing:antialiased;line-height:1.65;font-size:14.5px}
|
||||
.doc{max-width:860px;margin:0 auto;padding:44px 36px 90px}
|
||||
h1,h2,h3,h4,h5,h6{line-height:1.25;margin:1.6em 0 .55em;letter-spacing:-.01em}
|
||||
h1{font-size:26px;margin-top:.3em;padding-bottom:.45em;border-bottom:1px solid var(--border)}
|
||||
h1:after{content:'';display:block;width:56px;height:3px;margin-top:14px;border-radius:2px;background:linear-gradient(90deg,var(--accent),var(--pink))}
|
||||
h2{font-size:19px;padding-bottom:.3em;border-bottom:1px solid var(--border)}
|
||||
h3{font-size:15.5px}
|
||||
h4,h5,h6{font-size:13.5px;color:var(--text2);text-transform:uppercase;letter-spacing:.05em}
|
||||
p,ul,ol,blockquote,table,pre{margin-bottom:.9em}
|
||||
ul,ol{padding-left:1.5em}
|
||||
li{margin:.25em 0}
|
||||
li.task{list-style:none;margin-left:-1.3em}
|
||||
li.task input{margin-right:.5em;accent-color:var(--accent)}
|
||||
a{color:var(--accent);text-decoration:none;border-bottom:1px solid var(--accent-glow)}
|
||||
a:hover{border-bottom-color:var(--accent)}
|
||||
code{font-family:var(--mono);font-size:.88em;background:var(--bg3);border:1px solid var(--border);border-radius:4px;padding:.12em .38em}
|
||||
pre{background:var(--bg3);border:1px solid var(--border);border-radius:var(--radius);padding:14px 16px;overflow-x:auto}
|
||||
pre code{background:none;border:none;padding:0;font-size:12.5px;line-height:1.55}
|
||||
blockquote{border-left:3px solid var(--accent);background:var(--accent-glow);border-radius:0 var(--radius-sm) var(--radius-sm) 0;padding:8px 14px;color:var(--text2)}
|
||||
table{width:100%;border-collapse:collapse;font-size:13px;display:block;overflow-x:auto}
|
||||
th,td{text-align:left;padding:7px 12px;border:1px solid var(--border)}
|
||||
th{background:var(--bg3);font-weight:600;font-size:11.5px;text-transform:uppercase;letter-spacing:.04em;color:var(--text2);white-space:nowrap}
|
||||
tbody tr:hover{background:var(--surface-hover)}
|
||||
hr{border:none;border-top:1px solid var(--border);margin:1.6em 0}
|
||||
img{max-width:100%;border-radius:var(--radius-sm)}
|
||||
pre.mermaid{font-family:var(--mono);font-size:12.5px;line-height:1.55;white-space:pre-wrap}
|
||||
pre.mermaid[data-processed]{background:transparent;border:none;padding:4px 0;text-align:center;overflow-x:auto}
|
||||
pre.mermaid[data-processed] svg{max-width:100%;height:auto}
|
||||
pre.mermaid.mermaid-unrendered:before{content:'diagram source (renderer unavailable)';display:block;font-family:var(--font);font-size:10.5px;text-transform:uppercase;letter-spacing:.05em;color:var(--text3);margin-bottom:6px}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<article class="doc">
|
||||
${bodyHtml}
|
||||
</article>
|
||||
${hasMermaid ? mermaidLoaderScript(mermaidUrl()) : ''}
|
||||
<script src="${sdkSrc}"></script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
// 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))
|
||||
: `<a href="/canvas/${escapeHtml(s.key)}">${escapeHtml(path.basename(s.file))}</a>`;
|
||||
return `<tr><td>${link}</td><td class="mono">${escapeHtml(s.file)}</td><td><span class="badge ${escapeHtml(s.status)}">${status}</span></td></tr>`;
|
||||
}).join('\n');
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Plan Canvas · sessions</title>
|
||||
<style>
|
||||
${TOKENS_CSS}
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{font-family:var(--font);background:var(--bg);color:var(--text);padding:40px;line-height:1.5}
|
||||
.logo{width:30px;height:30px;background:linear-gradient(135deg,var(--accent),var(--pink));border-radius:7px;display:inline-flex;align-items:center;justify-content:center;font-weight:700;color:#fff;margin-right:10px;vertical-align:middle}
|
||||
h1{font-size:18px;display:inline-block;vertical-align:middle}
|
||||
table{margin-top:24px;border-collapse:collapse;width:100%;max-width:900px;font-size:13px}
|
||||
th,td{text-align:left;padding:8px 12px;border-bottom:1px solid var(--border)}
|
||||
th{color:var(--text3);font-size:11px;text-transform:uppercase;letter-spacing:.05em}
|
||||
a{color:var(--accent);text-decoration:none}
|
||||
.mono{font-family:var(--mono);font-size:11.5px;color:var(--text2)}
|
||||
.badge{font-size:11px;padding:2px 8px;border-radius:99px;background:var(--bg3);border:1px solid var(--border);color:var(--text2)}
|
||||
.badge.open,.badge.feedback{color:var(--green);border-color:var(--green);background:var(--green-glow)}
|
||||
.empty{margin-top:24px;color:var(--text3);font-size:13px}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<span class="logo">E</span><h1>Plan Canvas sessions</h1>
|
||||
${sessions.length ? `<table><thead><tr><th>Artifact</th><th>Path</th><th>Status</th></tr></thead><tbody>${rows}</tbody></table>` : '<p class="empty">No sessions yet. Ask your agent to open a plan with the plan-canvas skill.</p>'}
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
canvasCss,
|
||||
canvasClientJs,
|
||||
renderCanvasHtml,
|
||||
renderMarkdownArtifactHtml,
|
||||
renderSessionListHtml
|
||||
};
|
||||
Executable
+339
@@ -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 <file> --reply "Updated section 3."
|
||||
* node scripts/plan-canvas.js end <file>
|
||||
* 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 <file> Open (or resume) a review session',
|
||||
' node scripts/plan-canvas.js await <file> Block until the human sends feedback',
|
||||
' node scripts/plan-canvas.js end <file> 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 <msg> Show an agent reply in the canvas chat before waiting',
|
||||
' --timeout-ms <n> Return {status:"waiting"} after n ms (tests/debug only)',
|
||||
' server: --port <n> --host <h>',
|
||||
'',
|
||||
'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 <file>` 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 <file> --reply "<what you changed>"` 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 };
|
||||
@@ -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 <file> --reply "Split Phase 2 as requested — take a look."
|
||||
```
|
||||
|
||||
**4. End** when review concludes: `ecc-plan-canvas end <file>`.
|
||||
|
||||
## 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 <file> --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`.
|
||||
@@ -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();
|
||||
@@ -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('<h1 id="plan-real-time-notifications">'));
|
||||
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);
|
||||
});
|
||||
@@ -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();
|
||||
@@ -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\'>'),
|
||||
'<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),
|
||||
`<h${level} id="title-${level}">Title ${level}</h${level}>`
|
||||
);
|
||||
})) passed++; else failed++;
|
||||
}
|
||||
|
||||
if (test('heading supports inline formatting, slug ignores markers', () => {
|
||||
assert.strictEqual(
|
||||
renderMarkdown('## Rollout **Plan**'),
|
||||
'<h2 id="rollout-plan">Rollout <strong>Plan</strong></h2>'
|
||||
);
|
||||
})) 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'),
|
||||
'<p>first para</p>\n<p>second para</p>'
|
||||
);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('joins consecutive lines into one paragraph', () => {
|
||||
assert.strictEqual(renderMarkdown('line a\nline b'), '<p>line a\nline b</p>');
|
||||
})) 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,
|
||||
'<p>has <strong>bold</strong>, <em>ital</em>, <em>emph</em>, <del>gone</del>, and <code>a < b</code>.</p>'
|
||||
);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('does not italicize snake_case identifiers', () => {
|
||||
const out = renderMarkdown('use snake_case_name here');
|
||||
assert.ok(!out.includes('<em>'), `No <em> expected, got ${out}`);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('inline code contents are not parsed further', () => {
|
||||
assert.strictEqual(renderMarkdown('`**x**`'), '<p><code>**x**</code></p>');
|
||||
})) 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('<ul>'), 'Should start with <ul>');
|
||||
assert.ok(out.includes('<li>top one\n<ul>'), 'Nested list should sit inside first <li>');
|
||||
assert.ok(out.includes('<li>child one</li>'), 'Should contain first child');
|
||||
assert.ok(out.includes('</ul>\n</li>\n<li>top two</li>'), 'Second top item follows nested list');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('renders ordered list', () => {
|
||||
assert.strictEqual(
|
||||
renderMarkdown('1. first\n2. second'),
|
||||
'<ol>\n<li>first</li>\n<li>second</li>\n</ol>'
|
||||
);
|
||||
})) 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('<ol>'), 'Outer list should be <ol>');
|
||||
assert.ok(out.includes('<li>step one\n<ul>\n<li>detail</li>\n</ul>\n</li>'), `Nested <ul> 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('<li class="task"><input type="checkbox" disabled> draft plan</li>'), `Unchecked task expected, got ${out}`);
|
||||
assert.ok(out.includes('<li class="task"><input type="checkbox" disabled checked> review plan</li>'), `Checked task expected, got ${out}`);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('asterisk bullets work like hyphen bullets', () => {
|
||||
assert.strictEqual(renderMarkdown('* a\n* b'), '<ul>\n<li>a</li>\n<li>b</li>\n</ul>');
|
||||
})) 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('<table>'), 'Should start with <table>');
|
||||
assert.ok(out.includes('<thead>'), 'Should contain <thead>');
|
||||
assert.ok(out.includes('<tbody>'), 'Should contain <tbody>');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('applies alignment styles to header and body cells', () => {
|
||||
const out = renderMarkdown(planTable);
|
||||
assert.ok(out.includes('<th style="text-align:left">File</th>'), 'Left-aligned header');
|
||||
assert.ok(out.includes('<th style="text-align:center">Action</th>'), 'Center-aligned header');
|
||||
assert.ok(out.includes('<th style="text-align:right">Why</th>'), 'Right-aligned header');
|
||||
assert.ok(out.includes('<td style="text-align:center">Create</td>'), 'Center-aligned cell');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('renders inline code and bold inside table cells', () => {
|
||||
const out = renderMarkdown(planTable);
|
||||
assert.ok(out.includes('<code>scripts/lib/plan-canvas/markdown.js</code>'), 'Code span in cell');
|
||||
assert.ok(out.includes('<strong>Required</strong> 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('<th>A</th>'), 'Header without style');
|
||||
assert.ok(out.includes('<td>1</td>'), '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```'),
|
||||
'<pre><code class="language-js">const x = 1 < 2;</code></pre>'
|
||||
);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('escapes <script> inside code blocks', () => {
|
||||
const out = renderMarkdown('```html\n<script>alert(1)</script>\n```');
|
||||
assert.ok(!out.includes('<script>'), 'Raw script tag must not survive');
|
||||
assert.ok(out.includes('<script>alert(1)</script>'), 'Escaped script expected');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('does not parse inline markdown inside code blocks', () => {
|
||||
const out = renderMarkdown('```\n**not bold**\n```');
|
||||
assert.ok(out.includes('**not bold**'), 'Literal asterisks expected');
|
||||
assert.ok(!out.includes('<strong>'), 'No strong tag expected');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('sanitizes language attribute to [a-z0-9-]', () => {
|
||||
const out = renderMarkdown('```C++ extra info\ncode\n```');
|
||||
assert.ok(out.includes('class="language-c"'), `Sanitized lang expected, got ${out}`);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('unclosed fence consumes to end of input', () => {
|
||||
const out = renderMarkdown('```\nno closing fence');
|
||||
assert.strictEqual(out, '<pre><code>no closing fence</code></pre>');
|
||||
})) passed++; else failed++;
|
||||
|
||||
// Blockquote and horizontal rule tests
|
||||
console.log('\nBlockquotes and Rules:');
|
||||
|
||||
if (test('renders blockquote with inline formatting', () => {
|
||||
assert.strictEqual(
|
||||
renderMarkdown('> planning note with **bold**'),
|
||||
'<blockquote>\n<p>planning note with <strong>bold</strong></p>\n</blockquote>'
|
||||
);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('renders nested blockquotes', () => {
|
||||
const out = renderMarkdown('> outer\n> > inner');
|
||||
const opens = out.split('<blockquote>').length - 1;
|
||||
assert.strictEqual(opens, 2, `Expected 2 blockquotes, got ${opens}`);
|
||||
assert.ok(out.includes('<p>outer</p>'), 'Outer text expected');
|
||||
assert.ok(out.includes('<p>inner</p>'), 'Inner text expected');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('renders --- and *** as horizontal rules', () => {
|
||||
assert.strictEqual(
|
||||
renderMarkdown('above\n\n---\n\nbelow'),
|
||||
'<p>above</p>\n<hr>\n<p>below</p>'
|
||||
);
|
||||
assert.strictEqual(renderMarkdown('***'), '<hr>');
|
||||
})) passed++; else failed++;
|
||||
|
||||
// XSS tests
|
||||
console.log('\nXSS Hardening:');
|
||||
|
||||
if (test('escapes raw <script> in a paragraph', () => {
|
||||
const out = renderMarkdown('<script>alert(1)</script>');
|
||||
assert.strictEqual(out, '<p><script>alert(1)</script></p>');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('javascript: link renders as plain label text', () => {
|
||||
const out = renderMarkdown('[x](javascript:alert(1))');
|
||||
assert.ok(!out.includes('<a'), 'No anchor expected');
|
||||
assert.ok(!out.includes('javascript'), 'Payload URL must be dropped');
|
||||
assert.ok(out.includes('x'), 'Label text should remain');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('mixed-case JaVaScRiPt: link is blocked', () => {
|
||||
const out = renderMarkdown('[x](JaVaScRiPt:alert(1))');
|
||||
assert.ok(!out.includes('<a'), 'No anchor expected');
|
||||
assert.ok(!/javascript/i.test(out), 'Payload URL must be dropped');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('whitespace-obfuscated scheme is blocked', () => {
|
||||
const out = renderMarkdown('[x](java\tscript:alert(1))');
|
||||
assert.ok(!out.includes('<a'), 'No anchor expected');
|
||||
assert.ok(!out.includes('script:'), 'Payload URL must be dropped');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('data: and vbscript: links are blocked', () => {
|
||||
assert.strictEqual(renderMarkdown('[x](data:text/html;base64,AAAA)'), '<p>x</p>');
|
||||
const vb = renderMarkdown('[x](vbscript:msgbox(1))');
|
||||
assert.ok(!vb.includes('<a'), 'No anchor expected');
|
||||
assert.ok(!vb.includes('vbscript'), 'Payload URL must be dropped');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('javascript: image renders as plain alt text', () => {
|
||||
const out = renderMarkdown(')');
|
||||
assert.ok(!out.includes('<img'), 'No img expected');
|
||||
assert.ok(!out.includes('javascript'), 'Payload URL must be dropped');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('raw <img onerror> HTML is escaped', () => {
|
||||
const out = renderMarkdown('<img src=x onerror=alert(1)>');
|
||||
assert.strictEqual(out, '<p><img src=x onerror=alert(1)></p>');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('event-handler injection via link text is escaped', () => {
|
||||
const out = renderMarkdown('["><img src=x onerror=alert(1)>](https://evil.example)');
|
||||
assert.ok(!out.includes('<img'), 'No raw img expected');
|
||||
assert.ok(out.includes('"><img src=x onerror=alert(1)>'), `Escaped label expected, got ${out}`);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('image alt attribute value is escaped', () => {
|
||||
assert.strictEqual(
|
||||
renderMarkdown(''),
|
||||
'<p><img src="x.png" alt="a"b"></p>'
|
||||
);
|
||||
})) 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)'),
|
||||
'<p><a href="https://example.com" target="_blank" rel="noopener">docs</a></p>'
|
||||
);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('#anchor link has no target/rel', () => {
|
||||
assert.strictEqual(
|
||||
renderMarkdown('[phase](#phase-1)'),
|
||||
'<p><a href="#phase-1">phase</a></p>'
|
||||
);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('relative link has no target/rel', () => {
|
||||
assert.strictEqual(
|
||||
renderMarkdown('[utils](./scripts/lib/utils.js)'),
|
||||
'<p><a href="./scripts/lib/utils.js">utils</a></p>'
|
||||
);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('mailto link allowed without target/rel', () => {
|
||||
assert.strictEqual(
|
||||
renderMarkdown('[mail](mailto:team@example.com)'),
|
||||
'<p><a href="mailto:team@example.com">mail</a></p>'
|
||||
);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('relative image src allowed', () => {
|
||||
assert.strictEqual(
|
||||
renderMarkdown(''),
|
||||
'<p><img src="assets/plan.png" alt="diagram"></p>'
|
||||
);
|
||||
})) 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>code</code> and <strong>bold</strong> docs</a>'), `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'), '<p>final line</p>');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('CRLF line endings are normalized', () => {
|
||||
assert.strictEqual(renderMarkdown('one\r\n\r\ntwo'), '<p>one</p>\n<p>two</p>');
|
||||
})) 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 <pre class="mermaid">, not a code block', () => {
|
||||
const html = renderMarkdown('```mermaid\nflowchart LR\n A --> B\n```');
|
||||
assert.ok(html.includes('<pre class="mermaid">'), '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<script>alert(1)</script>\n```');
|
||||
assert.ok(!html.includes('<script>alert(1)</script>'));
|
||||
assert.ok(html.includes('<script>'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('a </pre> in the source cannot break out of the container', () => {
|
||||
const html = renderMarkdown('```mermaid\nA</pre><img src=x onerror=1>\n```');
|
||||
assert.ok(!html.includes('</pre><img'));
|
||||
assert.ok(html.includes('</pre><img'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
// Summary
|
||||
console.log('\n=== Test Results ===');
|
||||
console.log(`Passed: ${passed}`);
|
||||
console.log(`Failed: ${failed}`);
|
||||
console.log(`Total: ${passed + failed}\n`);
|
||||
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
runTests();
|
||||
@@ -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();
|
||||
@@ -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",
|
||||
|
||||
@@ -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, '<!DOCTYPE html><html><body><h1>Report</h1></body></html>');
|
||||
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('<h1 id="plan-demo">'));
|
||||
assert.ok(res.body.includes('<table>'));
|
||||
assert.ok(res.body.includes('<script src="/sdk.js">'));
|
||||
assert.strictEqual(res.headers['content-security-policy'], undefined);
|
||||
// No diagram in this plan → no Mermaid loader shipped.
|
||||
assert.ok(!res.body.includes('mermaid.run'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (await test('a plan containing ```mermaid serves the themed Mermaid loader', async () => {
|
||||
const diagram = path.join(tmp, 'flow.plan.md');
|
||||
fs.writeFileSync(diagram, '# Flow\n\n```mermaid\nflowchart LR\n A --> B\n```\n');
|
||||
const opened = jsonBody(await request(port, 'POST', '/api/sessions', { body: { file: diagram } }));
|
||||
const res = await request(port, 'GET', `/artifact/${opened.key}/`);
|
||||
assert.ok(res.body.includes('<pre class="mermaid">'), 'diagram container present');
|
||||
assert.ok(res.body.includes('mermaid.run'), 'loader injected');
|
||||
assert.ok(res.body.includes("securityLevel: 'strict'"), 'sanitizing config present');
|
||||
await request(port, 'POST', '/api/end', { body: { file: diagram } });
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (await test('HTML artifacts pass through with the SDK injected before </body>', async () => {
|
||||
const open = await request(port, 'POST', '/api/sessions', { body: { file: htmlArtifact } });
|
||||
htmlKey = jsonBody(open).key;
|
||||
const res = await request(port, 'GET', `/artifact/${htmlKey}/`);
|
||||
assert.ok(res.body.includes('<h1>Report</h1>'));
|
||||
assert.ok(res.body.includes('<script src="/sdk.js"></script>\n</body>'));
|
||||
})) 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);
|
||||
});
|
||||
Reference in New Issue
Block a user