liteagents 2.6.1 → 2.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/README.md +4 -2
  3. package/installer/cli.js +4 -1
  4. package/package.json +1 -1
  5. package/packages/ampcode/commands/live-canvas/README.md +38 -47
  6. package/packages/{claude/skills/live-canvas/templates/demo → ampcode/commands/live-canvas/dev}/post-variants.html +8 -3
  7. package/packages/ampcode/commands/live-canvas/templates/lab-banner.html +6 -0
  8. package/packages/ampcode/commands/live-canvas/templates/overlay-vanilla.js +64 -6
  9. package/packages/ampcode/commands/live-canvas.md +140 -161
  10. package/packages/ampcode/commands/security.md +50 -12
  11. package/packages/ampcode/commands/ship.md +33 -11
  12. package/packages/claude/commands/git-commit.md +1 -1
  13. package/packages/claude/commands/security.md +50 -12
  14. package/packages/claude/commands/ship.md +33 -11
  15. package/packages/claude/plugins/live-canvas-marketplace/plugins/live-canvas-channel/.claude-plugin/plugin.json +1 -1
  16. package/packages/claude/plugins/live-canvas-marketplace/plugins/live-canvas-channel/README.md +2 -2
  17. package/packages/claude/plugins/live-canvas-marketplace/plugins/live-canvas-channel/package.json +1 -1
  18. package/packages/claude/plugins/live-canvas-marketplace/plugins/live-canvas-channel/schema.json +1 -1
  19. package/packages/claude/plugins/live-canvas-marketplace/plugins/live-canvas-channel/server.js +320 -27
  20. package/packages/claude/plugins/live-canvas-marketplace/setup.sh +83 -24
  21. package/packages/claude/skills/live-canvas/README.md +53 -57
  22. package/packages/claude/skills/live-canvas/SKILL.md +122 -158
  23. package/packages/{ampcode/commands/live-canvas/templates/demo → claude/skills/live-canvas/dev}/post-variants.html +8 -3
  24. package/packages/claude/skills/live-canvas/templates/lab-banner.html +6 -0
  25. package/packages/claude/skills/live-canvas/templates/overlay-vanilla.js +64 -6
  26. package/packages/droid/commands/live-canvas/README.md +38 -47
  27. package/packages/droid/commands/live-canvas/{templates/demo → dev}/post-variants.html +8 -3
  28. package/packages/droid/commands/live-canvas/templates/lab-banner.html +6 -0
  29. package/packages/droid/commands/live-canvas/templates/overlay-vanilla.js +64 -6
  30. package/packages/droid/commands/live-canvas.md +140 -161
  31. package/packages/droid/commands/security.md +50 -12
  32. package/packages/droid/commands/ship.md +33 -11
  33. package/packages/opencode/command/live-canvas/README.md +38 -47
  34. package/packages/opencode/command/live-canvas/{templates/demo → dev}/post-variants.html +8 -3
  35. package/packages/opencode/command/live-canvas/templates/lab-banner.html +6 -0
  36. package/packages/opencode/command/live-canvas/templates/overlay-vanilla.js +64 -6
  37. package/packages/opencode/command/live-canvas.md +140 -161
  38. package/packages/opencode/command/security.md +50 -12
  39. package/packages/opencode/command/ship.md +33 -11
  40. package/packages/ampcode/commands/live-canvas/templates/.claude/settings.local.json +0 -8
  41. package/packages/ampcode/commands/live-canvas/templates/feedback-react/FeedbackOverlay.tsx +0 -1299
  42. package/packages/ampcode/commands/live-canvas/templates/feedback-react/format-utils.ts +0 -283
  43. package/packages/ampcode/commands/live-canvas/templates/feedback-react/index.ts +0 -62
  44. package/packages/ampcode/commands/live-canvas/templates/feedback-react/selector-utils.ts +0 -423
  45. package/packages/ampcode/commands/live-canvas/templates/feedback-react/types.ts +0 -118
  46. package/packages/claude/skills/live-canvas/INTEGRATION_NOTES.md +0 -210
  47. package/packages/claude/skills/live-canvas/templates/.claude/settings.local.json +0 -8
  48. package/packages/claude/skills/live-canvas/templates/feedback-react/FeedbackOverlay.tsx +0 -1299
  49. package/packages/claude/skills/live-canvas/templates/feedback-react/format-utils.ts +0 -283
  50. package/packages/claude/skills/live-canvas/templates/feedback-react/index.ts +0 -62
  51. package/packages/claude/skills/live-canvas/templates/feedback-react/selector-utils.ts +0 -423
  52. package/packages/claude/skills/live-canvas/templates/feedback-react/types.ts +0 -118
  53. package/packages/droid/commands/live-canvas/templates/.claude/settings.local.json +0 -8
  54. package/packages/droid/commands/live-canvas/templates/feedback-react/FeedbackOverlay.tsx +0 -1299
  55. package/packages/droid/commands/live-canvas/templates/feedback-react/format-utils.ts +0 -283
  56. package/packages/droid/commands/live-canvas/templates/feedback-react/index.ts +0 -62
  57. package/packages/droid/commands/live-canvas/templates/feedback-react/selector-utils.ts +0 -423
  58. package/packages/droid/commands/live-canvas/templates/feedback-react/types.ts +0 -118
  59. package/packages/opencode/command/live-canvas/templates/.claude/settings.local.json +0 -8
  60. package/packages/opencode/command/live-canvas/templates/feedback-react/FeedbackOverlay.tsx +0 -1299
  61. package/packages/opencode/command/live-canvas/templates/feedback-react/format-utils.ts +0 -283
  62. package/packages/opencode/command/live-canvas/templates/feedback-react/index.ts +0 -62
  63. package/packages/opencode/command/live-canvas/templates/feedback-react/selector-utils.ts +0 -423
  64. package/packages/opencode/command/live-canvas/templates/feedback-react/types.ts +0 -118
@@ -2,17 +2,39 @@
2
2
  name: ship
3
3
  description: Check pre-deployment
4
4
  usage: /ship
5
- allowed-tools: Bash(npm *), Bash(git *)
5
+ allowed-tools: Read, Grep, Glob, Bash(git *), Bash(npm *), Bash(pnpm *), Bash(yarn *), Bash(pytest *), Bash(python *), Bash(go *), Bash(cargo *), Bash(make *)
6
6
  ---
7
- Pre-deploy checklist:
7
+ Pre-deploy / pre-merge gate. **Detect the stack first** (look for
8
+ `package.json`, `pyproject.toml`/`setup.cfg`, `go.mod`, `Cargo.toml`,
9
+ `Makefile`) and run only the checks that actually exist — never assume a
10
+ script (`lint`, `build`, `migrate`) is present. Report each item as
11
+ **pass / fail / N/A**.
8
12
 
9
- - [ ] Tests pass (`npm test`)
10
- - [ ] Lint clean (`npm run lint`)
11
- - [ ] Build succeeds (`npm run build`)
12
- - [ ] No console.log/debugger statements
13
- - [ ] Env vars documented
14
- - [ ] No hardcoded secrets
15
- - [ ] Error handling complete
16
- - [ ] Migrations ready
13
+ ## Checklist
14
+ - [ ] **Tests pass** — run the project's real test command (`npm test`,
15
+ `pytest`, `go test ./...`, `cargo test`, `make test`).
16
+ - [ ] **Lint / format clean** — only if a linter or formatter is configured.
17
+ - [ ] **Build succeeds** — only if the project has a build step.
18
+ - [ ] **No debug leftovers** — stray `console.log` / `print` / `debugger` /
19
+ `dbg!` / commented-out blocks / blocker `TODO`s in the changed files.
20
+ - [ ] **No hardcoded secrets** — scan the diff. Secrets load from env / a
21
+ secret store; `.env` is gitignored and only a value-less `.env.example`
22
+ is tracked.
23
+ - [ ] **Error handling complete** — every new IO / network / DB call has a
24
+ failure path; nothing fails silently; no internal detail leaks to clients.
25
+ - [ ] **Authorization** — new endpoints/actions check **ownership + role**,
26
+ not just authentication (no IDOR via id-swapping).
27
+ - [ ] **Rate limiting** — new externally reachable routes, including
28
+ authenticated writes, are bounded.
29
+ - [ ] **Data access scoped & scales** — new queries are constrained to the
30
+ requesting principal (no cross-tenant leak) and avoid obvious N+1 /
31
+ unindexed scans on hot paths.
32
+ - [ ] **Migrations ready** — only if the project has a schema / migrations.
33
+ - [ ] **Docs & config in sync** — `.env.example`, README, and any
34
+ threat-model / PRD updated for new config or new attack surface.
35
+ - [ ] **Clean tree, correct branch, in sync with `origin`.**
17
36
 
18
- Run checks and report: Ready 🚀 or Blocked 🛑 with issues.
37
+ For any security-sensitive change in the diff, run **`/security`** on the
38
+ changed files before shipping.
39
+
40
+ Report: **Ready 🚀** or **Blocked 🛑** with the specific failing items.
@@ -2,7 +2,7 @@
2
2
  name: git-commit
3
3
  description: Analyze changes and commit
4
4
  usage: /git-commit
5
- allowed-tools: Bash(git *)
5
+ allowed-tools: Bash(git:*)
6
6
  ---
7
7
  1. Run `git diff --staged` to see changes
8
8
  2. Analyze what was changed and why
@@ -3,19 +3,57 @@ name: security
3
3
  description: Scan security [target]
4
4
  usage: /security
5
5
  argument-hint: [file, directory, or leave empty for full scan]
6
- allowed-tools: Read, Grep, Glob
6
+ allowed-tools: Read, Grep, Glob, Bash(git log:*), Bash(git grep:*), Bash(rg:*)
7
7
  ---
8
- Audit $ARGUMENTS for security vulnerabilities.
8
+ Audit $ARGUMENTS for security vulnerabilities. Adapt scope to what the target
9
+ actually is — a library, CLI, web app, and service won't all have every
10
+ category. Skip what genuinely doesn't apply; never invent findings to fill a
11
+ section.
9
12
 
10
- ## Check For
11
- - Injection: SQL, command, XSS, template
12
- - Auth: Weak passwords, session issues, CSRF
13
- - Data: Exposure, logging secrets, insecure storage
14
- - Config: Debug mode, default creds, missing headers
15
- - Dependencies: Known CVEs
13
+ ## The recurring six (check every project, where applicable)
14
+ These show up in nearly every quickly-built app regardless of stack:
15
+
16
+ 1. **Secrets in the repo.** Tokens / API keys / `.env` files committed to
17
+ tracked files or anywhere in git history. Verify `.env` is gitignored and
18
+ only a value-less `.env.example` is tracked; scan history (`git log -p`,
19
+ `git grep`) for leaked keys. Secrets must load from env / a secret store at
20
+ runtime — never hardcoded, never logged.
21
+ 2. **Data-access authorization (tenant isolation).** Every record read or
22
+ written must be scoped to the requesting principal — via DB-level rules
23
+ (RLS / row policies) and/or application-layer ownership checks. Flag any
24
+ query that trusts a client-supplied id without an ownership or role gate,
25
+ and any table/collection with a policy that's too broad or missing.
26
+ 3. **Rate limiting.** Every externally reachable endpoint and abuse-prone
27
+ inbound path is bounded — including **authenticated mutation/write routes**,
28
+ not just the obvious public GETs. Note any unbounded route.
29
+ 4. **Error handling past the happy path.** Third-party / IO / DB failures are
30
+ caught; nothing fails silently; no internal detail (stack traces, queries,
31
+ secrets) leaks to the client. Background/async work has its own catch.
32
+ 5. **Authorization beyond authentication (IDOR / privilege).** "Logged in" is
33
+ not "allowed to do this". Confirm ownership AND role/permission checks on
34
+ every state-changing or privileged action. Mentally swap an id in a request
35
+ — does it return 403, or does it leak/modify another user's data?
36
+ 6. **Inefficient data access (N+1 / unindexed).** Queries inside loops,
37
+ per-render repeated calls, missing indexes on filtered/joined columns.
38
+ Correct but falls over under load — a real availability risk.
39
+
40
+ ## Also scan for
41
+ - **Injection:** SQL, command, XSS, template, path traversal.
42
+ - **Auth/session:** weak token handling, CSRF, session fixation, predictable ids.
43
+ - **Trust boundaries:** spoofable headers (e.g. `X-Forwarded-For`) trusted
44
+ without a vetted proxy; unvalidated untrusted input (uploads, inbound mail,
45
+ webhooks); services bound to `0.0.0.0` that should be loopback-only.
46
+ - **Config:** debug mode on in prod, default creds, missing security headers,
47
+ permissive CORS.
48
+ - **Dependencies:** known CVEs; unmaintained or single-maintainer deps in
49
+ security-critical paths.
16
50
 
17
51
  ## Output
18
- Severity-ranked findings with:
19
- - Location (file:line)
20
- - Risk explanation
21
- - Remediation steps
52
+ Severity-ranked findings (Critical → High → Medium → Low), each with:
53
+ - **Location** (`file:line`)
54
+ - **Risk** — what an attacker actually gains
55
+ - **Remediation** — concrete, minimal fix
56
+
57
+ End with: which of the six classes were checked and found **clean**, and any
58
+ marked **N/A** for this target — so the scan's coverage is auditable, not just
59
+ its hits.
@@ -2,17 +2,39 @@
2
2
  name: ship
3
3
  description: Check pre-deployment
4
4
  usage: /ship
5
- allowed-tools: Bash(npm *), Bash(git *)
5
+ allowed-tools: Read, Grep, Glob, Bash(git:*), Bash(npm:*), Bash(pnpm:*), Bash(yarn:*), Bash(pytest:*), Bash(python:*), Bash(go:*), Bash(cargo:*), Bash(make:*)
6
6
  ---
7
- Pre-deploy checklist:
7
+ Pre-deploy / pre-merge gate. **Detect the stack first** (look for
8
+ `package.json`, `pyproject.toml`/`setup.cfg`, `go.mod`, `Cargo.toml`,
9
+ `Makefile`) and run only the checks that actually exist — never assume a
10
+ script (`lint`, `build`, `migrate`) is present. Report each item as
11
+ **pass / fail / N/A**.
8
12
 
9
- - [ ] Tests pass (`npm test`)
10
- - [ ] Lint clean (`npm run lint`)
11
- - [ ] Build succeeds (`npm run build`)
12
- - [ ] No console.log/debugger statements
13
- - [ ] Env vars documented
14
- - [ ] No hardcoded secrets
15
- - [ ] Error handling complete
16
- - [ ] Migrations ready
13
+ ## Checklist
14
+ - [ ] **Tests pass** — run the project's real test command (`npm test`,
15
+ `pytest`, `go test ./...`, `cargo test`, `make test`).
16
+ - [ ] **Lint / format clean** — only if a linter or formatter is configured.
17
+ - [ ] **Build succeeds** — only if the project has a build step.
18
+ - [ ] **No debug leftovers** — stray `console.log` / `print` / `debugger` /
19
+ `dbg!` / commented-out blocks / blocker `TODO`s in the changed files.
20
+ - [ ] **No hardcoded secrets** — scan the diff. Secrets load from env / a
21
+ secret store; `.env` is gitignored and only a value-less `.env.example`
22
+ is tracked.
23
+ - [ ] **Error handling complete** — every new IO / network / DB call has a
24
+ failure path; nothing fails silently; no internal detail leaks to clients.
25
+ - [ ] **Authorization** — new endpoints/actions check **ownership + role**,
26
+ not just authentication (no IDOR via id-swapping).
27
+ - [ ] **Rate limiting** — new externally reachable routes, including
28
+ authenticated writes, are bounded.
29
+ - [ ] **Data access scoped & scales** — new queries are constrained to the
30
+ requesting principal (no cross-tenant leak) and avoid obvious N+1 /
31
+ unindexed scans on hot paths.
32
+ - [ ] **Migrations ready** — only if the project has a schema / migrations.
33
+ - [ ] **Docs & config in sync** — `.env.example`, README, and any
34
+ threat-model / PRD updated for new config or new attack surface.
35
+ - [ ] **Clean tree, correct branch, in sync with `origin`.**
17
36
 
18
- Run checks and report: Ready 🚀 or Blocked 🛑 with issues.
37
+ For any security-sensitive change in the diff, run **`/security`** on the
38
+ changed files before shipping.
39
+
40
+ Report: **Ready 🚀** or **Blocked 🛑** with the specific failing items.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "live-canvas-channel",
3
- "version": "0.1.0",
3
+ "version": "0.5.0",
4
4
  "description": "Bridges the Live Canvas browser overlay to a Claude Code session. Each overlay Save becomes a claude/channel notification delivered over MCP.",
5
5
  "author": { "name": "liteagents" },
6
6
  "channels": [
@@ -12,7 +12,7 @@ Claude Code channel plugin that bridges the Live Canvas browser overlay to a run
12
12
  ```
13
13
 
14
14
  - HTTP listener: `127.0.0.1:8788` (override with `LIVE_CANVAS_PORT`)
15
- - `GET /health` — overlay probes this to select Live vs Batch mode
15
+ - `GET /health` — overlay probes this to confirm Live mode is reachable
16
16
  - `POST /feedback` — wire-compatible with the overlay's v1.0 schema
17
17
  - stdio: MCP protocol using `@modelcontextprotocol/sdk`
18
18
 
@@ -84,6 +84,6 @@ curl -X POST http://localhost:8788/feedback \
84
84
 
85
85
  ## Failure modes
86
86
 
87
- - **Port in use** — another Claude session owns 8788. Logs to stderr and exits; overlay health probe fails; overlay falls back to Batch mode silently.
87
+ - **Port in use** — another Claude session owns 8788. Logs to stderr and exits; overlay health probe fails; overlay falls back to JSON mode silently. The skill itself never silently degrades — it stops and asks the user.
88
88
  - **Invalid payload** — HTTP 400 with reason; no notification emitted.
89
89
  - **MCP transport not connected** — HTTP call still returns 200 (so the overlay shows "pushed"), but stderr logs the drop. Avoids false-negative toasts.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "live-canvas-channel",
3
- "version": "0.1.0",
3
+ "version": "0.5.0",
4
4
  "description": "MCP channel server: bridges Live Canvas HTTP overlay events into the Claude Code session.",
5
5
  "main": "server.js",
6
6
  "type": "commonjs",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "http://json-schema.org/draft-07/schema#",
3
3
  "title": "LiveCanvasFeedback",
4
- "description": "Payload shape posted by the Live Canvas overlay to POST /feedback. Wire-compatible with the v1.0 schema produced by overlay-vanilla.js and the upstream React FeedbackOverlay.",
4
+ "description": "Payload shape posted by the Live Canvas overlay to POST /feedback. v1.0 schema produced by overlay-vanilla.js.",
5
5
  "type": "object",
6
6
  "required": ["version", "target", "comment"],
7
7
  "properties": {
@@ -2,14 +2,15 @@
2
2
  /**
3
3
  * live-canvas-channel — MCP channel server for Claude Code.
4
4
  *
5
- * Two concurrent duties:
6
- * 1. HTTP listener (LIVE_CANVAS_PORT, default 8788) accepts feedback
7
- * POSTs from the Live Canvas overlay running in the browser. The
8
- * overlay probes GET /health to decide Live vs Batch mode.
9
- * 2. MCP stdio server declares the `experimental: claude/channel`
10
- * capability and, on each valid POST, emits a
11
- * `notifications/claude/channel` notification so the feedback lands
12
- * in the active Claude turn.
5
+ * Three duties:
6
+ * 1. MCP stdio server connects at startup so `/mcp` shows green in
7
+ * every Claude session, even sessions that never touch live-canvas.
8
+ * 2. Tools `channel_open` / `channel_close` lazily bind/release the
9
+ * HTTP listener on LIVE_CANVAS_PORT (default 8788). Only the session
10
+ * that calls `channel_open` owns the port; other sessions stay idle.
11
+ * 3. HTTP listener (only while open) — accepts feedback POSTs from the
12
+ * browser overlay and emits a `notifications/claude/channel`
13
+ * notification into the owning session's turn.
13
14
  *
14
15
  * Protocol reference: https://code.claude.com/docs/en/channels-reference
15
16
  */
@@ -17,19 +18,28 @@
17
18
  'use strict';
18
19
 
19
20
  const http = require('http');
21
+ const fs = require('fs');
22
+ const path = require('path');
20
23
  const { Server } = require('@modelcontextprotocol/sdk/server/index.js');
21
24
  const { StdioServerTransport } = require('@modelcontextprotocol/sdk/server/stdio.js');
25
+ const {
26
+ ListToolsRequestSchema,
27
+ CallToolRequestSchema,
28
+ } = require('@modelcontextprotocol/sdk/types.js');
22
29
 
23
30
  const PORT = Number(process.env.LIVE_CANVAS_PORT || 8788);
24
31
  const SERVER_NAME = 'live-canvas';
25
- const SERVER_VERSION = '0.1.0';
32
+ const SERVER_VERSION = '0.5.0';
26
33
 
27
34
  // ---------- MCP server ----------
28
35
 
29
36
  const mcp = new Server(
30
37
  { name: SERVER_NAME, version: SERVER_VERSION },
31
38
  {
32
- capabilities: { experimental: { 'claude/channel': {} } },
39
+ capabilities: {
40
+ tools: {},
41
+ experimental: { 'claude/channel': {} },
42
+ },
33
43
  instructions: [
34
44
  'Events from the live-canvas channel arrive as <channel source="live-canvas" ...> tags.',
35
45
  'Each event is a single click-to-annotate comment made by the user on a UI variant in their browser.',
@@ -38,6 +48,7 @@ const mcp = new Server(
38
48
  'When one arrives: acknowledge the feedback briefly, then edit the corresponding variant file',
39
49
  'in .claude-design/lab/ using the selector to locate the element. This is a one-way channel —',
40
50
  'no reply tool; respond to the user in chat as you would any normal message.',
51
+ 'Before entering Live mode, call the channel_open tool; on exit or abort, call channel_close.',
41
52
  ].join(' '),
42
53
  }
43
54
  );
@@ -80,7 +91,6 @@ function validate(payload) {
80
91
  return null;
81
92
  }
82
93
 
83
- // Human-readable body the assistant will see inside the Claude turn.
84
94
  function formatContent(target, comment) {
85
95
  const { variant, element, text } = comment;
86
96
  const label = element.textContent ? `, ${element.tagName} with "${element.textContent}"` : '';
@@ -91,7 +101,6 @@ function formatContent(target, comment) {
91
101
  ].join('\n');
92
102
  }
93
103
 
94
- // All values in `meta` must be strings per the claude/channel contract.
95
104
  function buildMeta(target, comment) {
96
105
  return {
97
106
  source: 'live-canvas',
@@ -103,7 +112,7 @@ function buildMeta(target, comment) {
103
112
  };
104
113
  }
105
114
 
106
- // ---------- HTTP server ----------
115
+ // ---------- HTTP server (created but not bound) ----------
107
116
 
108
117
  const server = http.createServer(async (req, res) => {
109
118
  cors(res);
@@ -116,6 +125,27 @@ const server = http.createServer(async (req, res) => {
116
125
  return;
117
126
  }
118
127
 
128
+ if (req.method === 'POST' && req.url === '/feedback-jsonl') {
129
+ // JSON-mode batch endpoint: appends the submitted payload to
130
+ // <parent claude cwd>/.claude-design/feedback.jsonl so the user doesn't
131
+ // have to download a file and paste. No capability gate — JSON mode
132
+ // doesn't depend on the experimental channels flag.
133
+ try {
134
+ const raw = await readBody(req);
135
+ JSON.parse(raw); // validate JSON only — schema is overlay-defined
136
+ const dir = path.join(process.cwd(), '.claude-design');
137
+ fs.mkdirSync(dir, { recursive: true });
138
+ const file = path.join(dir, 'feedback.jsonl');
139
+ fs.appendFileSync(file, raw.trim() + '\n');
140
+ res.writeHead(200, { 'content-type': 'application/json' });
141
+ res.end(JSON.stringify({ ok: true, path: file }));
142
+ } catch (e) {
143
+ res.writeHead(400, { 'content-type': 'application/json' });
144
+ res.end(JSON.stringify({ error: String(e.message || e) }));
145
+ }
146
+ return;
147
+ }
148
+
119
149
  if (req.method === 'POST' && req.url === '/feedback') {
120
150
  try {
121
151
  const raw = await readBody(req);
@@ -133,8 +163,6 @@ const server = http.createServer(async (req, res) => {
133
163
  buildMeta(payload.target, payload.comment)
134
164
  );
135
165
  } catch (e) {
136
- // MCP transport not yet connected or closed — still ack the HTTP
137
- // caller so the overlay doesn't false-negative.
138
166
  process.stderr.write(`[live-canvas] notify failed: ${e.message || e}\n`);
139
167
  }
140
168
 
@@ -150,28 +178,293 @@ const server = http.createServer(async (req, res) => {
150
178
  res.writeHead(404); res.end();
151
179
  });
152
180
 
153
- server.on('error', (err) => {
154
- if (err.code === 'EADDRINUSE') {
155
- process.stderr.write(`[live-canvas] port ${PORT} in use another session likely owns it; exiting.\n`);
156
- process.exit(0);
181
+ // ---------- channel capability check ----------
182
+
183
+ // Channels are still an experimental Claude Code feature. They only deliver
184
+ // `<channel>` tags into sessions launched with --dangerously-load-development-channels.
185
+ // Without that flag, a plain `claude` session can still load the MCP and bind the
186
+ // port, but every notification we emit is silently dropped — producing the
187
+ // "POST 200, but nothing landed" black hole. Detect by inspecting the parent
188
+ // claude's command-line; refuse to bind from non-qualifying sessions.
189
+ const RELAUNCH_HINT = 'Restart this session with: live-claude (or: claude --dangerously-load-development-channels plugin:live-canvas-channel@live-canvas-marketplace)';
190
+
191
+ function parentHasChannelsFlag() {
192
+ const FLAG = '--dangerously-load-development-channels';
193
+ const ppid = process.ppid;
194
+
195
+ // Linux: /proc/<pid>/cmdline is NUL-separated argv. Fast, no subprocess.
196
+ try {
197
+ const raw = require('fs').readFileSync(`/proc/${ppid}/cmdline`, 'utf8');
198
+ return raw.split('\0').some((arg) => arg === FLAG);
199
+ } catch { /* fall through */ }
200
+
201
+ // macOS / BSD / any POSIX without /proc: `ps -p <pid> -o args=` prints the
202
+ // full command line. Whitespace splits are fine here — the flag is a single
203
+ // token with no quoting needed.
204
+ if (process.platform !== 'win32') {
205
+ try {
206
+ const out = require('child_process')
207
+ .execFileSync('ps', ['-p', String(ppid), '-o', 'args='], { encoding: 'utf8', timeout: 1000 });
208
+ return out.split(/\s+/).some((arg) => arg === FLAG);
209
+ } catch { /* fall through */ }
157
210
  }
158
- process.stderr.write(`[live-canvas] http error: ${err}\n`);
159
- process.exit(1);
211
+
212
+ // Windows: `wmic process where processid=<pid> get commandline /value`
213
+ // emits "CommandLine=<full string>". We split on whitespace then match.
214
+ if (process.platform === 'win32') {
215
+ try {
216
+ const out = require('child_process')
217
+ .execFileSync('wmic', ['process', 'where', `processid=${ppid}`, 'get', 'commandline', '/value'], { encoding: 'utf8', timeout: 1500 });
218
+ return out.split(/\s+/).some((arg) => arg === FLAG);
219
+ } catch { /* fall through */ }
220
+ }
221
+
222
+ // Couldn't determine — fail closed. Better a wrong "no_channel_capability"
223
+ // error than a silent black hole. User can read the message and rerun.
224
+ return false;
225
+ }
226
+
227
+ // ---------- lazy bind / release ----------
228
+
229
+ // Resolve who's holding `port`. Tries `ss` first (fast on Linux), then `lsof`
230
+ // (cross-platform). Returns null if we can't tell.
231
+ function findPortHolder(port) {
232
+ const { execFileSync } = require('child_process');
233
+ try {
234
+ const out = execFileSync('ss', ['-lntpH', `sport = :${port}`], { encoding: 'utf8', timeout: 800 });
235
+ const m = out.match(/pid=(\d+)/);
236
+ if (m) return Number(m[1]);
237
+ } catch { /* not Linux or ss unavailable */ }
238
+ try {
239
+ const out = execFileSync('lsof', ['-iTCP:' + port, '-sTCP:LISTEN', '-t'], { encoding: 'utf8', timeout: 800 });
240
+ const first = out.trim().split('\n')[0];
241
+ const pid = Number(first);
242
+ if (pid > 0) return pid;
243
+ } catch { /* lsof missing */ }
244
+ return null;
245
+ }
246
+
247
+ // True iff `pid` is another instance of THIS server.js running as the same uid.
248
+ // "Same plugin binary + same user" is the criterion for safe takeover — it can
249
+ // only be another /live-canvas in another of this user's Claude sessions.
250
+ function isMyPluginServer(pid) {
251
+ if (!pid || pid === process.pid) return false;
252
+ const myUid = (typeof process.getuid === 'function') ? process.getuid() : null;
253
+
254
+ // Linux: read /proc/<pid>/cmdline + /proc/<pid>/status
255
+ try {
256
+ const cmdline = require('fs').readFileSync(`/proc/${pid}/cmdline`, 'utf8');
257
+ if (!cmdline.split('\0').includes(__filename)) return false;
258
+ if (myUid !== null) {
259
+ const status = require('fs').readFileSync(`/proc/${pid}/status`, 'utf8');
260
+ const m = status.match(/^Uid:\s+(\d+)/m);
261
+ if (m && Number(m[1]) !== myUid) return false;
262
+ }
263
+ return true;
264
+ } catch { /* fall through */ }
265
+
266
+ // macOS / BSD: ps args + ps user
267
+ try {
268
+ const { execFileSync } = require('child_process');
269
+ const args = execFileSync('ps', ['-p', String(pid), '-o', 'args='], { encoding: 'utf8', timeout: 800 });
270
+ if (!args.includes(__filename)) return false;
271
+ if (myUid !== null) {
272
+ const uidOut = execFileSync('ps', ['-p', String(pid), '-o', 'uid='], { encoding: 'utf8', timeout: 800 });
273
+ if (Number(uidOut.trim()) !== myUid) return false;
274
+ }
275
+ return true;
276
+ } catch { /* fall through */ }
277
+
278
+ return false;
279
+ }
280
+
281
+ function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); }
282
+
283
+ // One bind attempt. Resolves on success/EADDRINUSE; rejects on other errors.
284
+ function tryBind() {
285
+ return new Promise((resolve, reject) => {
286
+ const onError = (err) => {
287
+ server.off('error', onError);
288
+ if (err.code === 'EADDRINUSE') resolve(false);
289
+ else reject(err);
290
+ };
291
+ server.once('error', onError);
292
+ server.listen(PORT, '127.0.0.1', () => {
293
+ server.off('error', onError);
294
+ resolve(true);
295
+ });
296
+ });
297
+ }
298
+
299
+ async function openChannel() {
300
+ const cap = parentHasChannelsFlag();
301
+ if (cap === false) {
302
+ return {
303
+ status: 'no_channel_capability',
304
+ port: PORT,
305
+ message: `This Claude session was launched without --dangerously-load-development-channels — channel notifications would be silently dropped. ${RELAUNCH_HINT}`,
306
+ };
307
+ }
308
+ if (server.listening) return { status: 'already_listening', port: PORT };
309
+
310
+ if (await tryBind()) {
311
+ process.stderr.write(`[live-canvas] listening on 127.0.0.1:${PORT}\n`);
312
+ return { status: 'opened', port: PORT };
313
+ }
314
+
315
+ // Port busy. If the holder is another instance of this same plugin running
316
+ // as the same uid, take it over — same user, same binary, definitionally
317
+ // safe. (The flag gate above already authorized this caller for channels.)
318
+ const holder = findPortHolder(PORT);
319
+ if (holder && isMyPluginServer(holder)) {
320
+ process.stderr.write(`[live-canvas] taking over port ${PORT} from sibling pid ${holder}\n`);
321
+ try { process.kill(holder, 'SIGTERM'); } catch { /* already gone */ }
322
+
323
+ // Poll for the port to free (the sibling's shutdown closes its listener).
324
+ for (let i = 0; i < 15; i++) {
325
+ await sleep(100);
326
+ if (await tryBind()) {
327
+ process.stderr.write(`[live-canvas] listening on 127.0.0.1:${PORT} (took over from pid ${holder})\n`);
328
+ return { status: 'opened', port: PORT, took_over: holder };
329
+ }
330
+ }
331
+ // Last resort — SIGKILL the holdout, one more try.
332
+ try { process.kill(holder, 'SIGKILL'); } catch {}
333
+ await sleep(200);
334
+ if (await tryBind()) {
335
+ process.stderr.write(`[live-canvas] listening on 127.0.0.1:${PORT} (force-killed pid ${holder})\n`);
336
+ return { status: 'opened', port: PORT, took_over: holder, force_killed: true };
337
+ }
338
+ return {
339
+ status: 'in_use',
340
+ port: PORT,
341
+ holder_pid: holder,
342
+ message: `Tried to take over port ${PORT} from sibling pid ${holder} but it wouldn't release. Manual cleanup needed: kill -9 ${holder}`,
343
+ };
344
+ }
345
+
346
+ // Held by something else (different plugin, different user, or unknown).
347
+ return {
348
+ status: 'in_use',
349
+ port: PORT,
350
+ holder_pid: holder,
351
+ message: holder
352
+ ? `Port ${PORT} is held by pid ${holder} (not a live-canvas server). Stop that process or pick JSON mode. To inspect: ps -fp ${holder}`
353
+ : `Port ${PORT} is held by an unknown process. Find it with: ss -lntp | grep ${PORT}`,
354
+ };
355
+ }
356
+
357
+ function closeChannel() {
358
+ if (!server.listening) {
359
+ return Promise.resolve({ status: 'not_listening', port: PORT });
360
+ }
361
+ return new Promise((resolve) => {
362
+ server.close(() => {
363
+ process.stderr.write(`[live-canvas] released port ${PORT}\n`);
364
+ resolve({ status: 'closed', port: PORT });
365
+ });
366
+ });
367
+ }
368
+
369
+ // JSON-mode bind. No flag gate — channels aren't used, only /feedback-jsonl.
370
+ // Lets a plain `claude` session bind the port so overlay submissions write
371
+ // straight to <cwd>/.claude-design/feedback.jsonl instead of triggering a
372
+ // browser download.
373
+ function openBatch() {
374
+ if (server.listening) {
375
+ return Promise.resolve({ status: 'already_listening', port: PORT });
376
+ }
377
+ return new Promise((resolve, reject) => {
378
+ const onError = (err) => {
379
+ server.off('error', onError);
380
+ if (err.code === 'EADDRINUSE') {
381
+ resolve({
382
+ status: 'in_use',
383
+ port: PORT,
384
+ message: `Port ${PORT} is held by another live-canvas session. JSON-mode submissions in this session will fall back to browser download instead of writing to .claude-design/feedback.jsonl.`,
385
+ });
386
+ } else {
387
+ reject(err);
388
+ }
389
+ };
390
+ server.once('error', onError);
391
+ server.listen(PORT, '127.0.0.1', () => {
392
+ server.off('error', onError);
393
+ process.stderr.write(`[live-canvas] listening on 127.0.0.1:${PORT} (batch-only, no channels)\n`);
394
+ resolve({ status: 'opened', port: PORT });
395
+ });
396
+ });
397
+ }
398
+
399
+ // ---------- tools ----------
400
+
401
+ mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
402
+ tools: [
403
+ {
404
+ name: 'channel_open',
405
+ description: 'Bind the live-canvas HTTP listener on port 8788 so the browser overlay can POST feedback into this session. Call once when entering Live mode. Returns {status, port, message?, took_over?, holder_pid?}. status: "opened" (bound, possibly after taking over a sibling live-canvas server — `took_over` is set to the prior pid if so), "already_listening" (we already had it), "in_use" (held by something that is NOT another live-canvas; `holder_pid` and `message` describe it), or "no_channel_capability" (this session lacks --dangerously-load-development-channels — show the user the message and stop, do not proceed to Live mode).',
406
+ inputSchema: { type: 'object', properties: {}, additionalProperties: false },
407
+ },
408
+ {
409
+ name: 'channel_close',
410
+ description: 'Release the live-canvas HTTP listener so another session can claim it. Call on /live-canvas cleanup, abort, or when the user is done with Live mode. Returns {status: "closed" | "not_listening", port}.',
411
+ inputSchema: { type: 'object', properties: {}, additionalProperties: false },
412
+ },
413
+ {
414
+ name: 'batch_open',
415
+ description: 'Bind the live-canvas HTTP listener on port 8788 for JSON mode only (no channel notifications, no flag required). Call this in JSON mode so the browser overlay can POST submissions to /feedback-jsonl which writes to <cwd>/.claude-design/feedback.jsonl. Returns {status: "opened" | "already_listening" | "in_use", port, message?}. If status is "in_use", JSON submissions fall back to browser download.',
416
+ inputSchema: { type: 'object', properties: {}, additionalProperties: false },
417
+ },
418
+ ],
419
+ }));
420
+
421
+ mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
422
+ const { name } = req.params;
423
+ let result;
424
+ if (name === 'channel_open') {
425
+ result = await openChannel();
426
+ } else if (name === 'channel_close') {
427
+ result = await closeChannel();
428
+ } else if (name === 'batch_open') {
429
+ result = await openBatch();
430
+ } else {
431
+ throw new Error(`Unknown tool: ${name}`);
432
+ }
433
+ return { content: [{ type: 'text', text: JSON.stringify(result) }] };
160
434
  });
161
435
 
162
- // ---------- startup ----------
436
+ // ---------- startup / shutdown ----------
163
437
 
164
438
  async function main() {
165
439
  const transport = new StdioServerTransport();
166
440
  await mcp.connect(transport);
167
441
 
168
- server.listen(PORT, '127.0.0.1', () => {
169
- process.stderr.write(`[live-canvas] listening on 127.0.0.1:${PORT}\n`);
170
- });
442
+ // When the MCP host disconnects (session closed, /reload-plugins), release
443
+ // the port if we were holding it. Without this the next bind hits EADDRINUSE.
444
+ let closing = false;
445
+ const shutdown = () => {
446
+ if (closing) return;
447
+ closing = true;
448
+ if (server.listening) {
449
+ server.close(() => process.exit(0));
450
+ setTimeout(() => process.exit(0), 500).unref();
451
+ } else {
452
+ process.exit(0);
453
+ }
454
+ };
455
+ transport.onclose = shutdown;
456
+ process.stdin.on('end', shutdown);
457
+ process.stdin.on('close', shutdown);
171
458
  }
172
459
 
173
- process.on('SIGTERM', () => { server.close(() => process.exit(0)); });
174
- process.on('SIGINT', () => { server.close(() => process.exit(0)); });
460
+ process.on('SIGTERM', () => {
461
+ if (server.listening) server.close(() => process.exit(0));
462
+ else process.exit(0);
463
+ });
464
+ process.on('SIGINT', () => {
465
+ if (server.listening) server.close(() => process.exit(0));
466
+ else process.exit(0);
467
+ });
175
468
 
176
469
  main().catch((err) => {
177
470
  process.stderr.write(`[live-canvas] fatal: ${err}\n`);