liteagents 2.6.0 → 2.8.0

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 (57) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/LICENSE +202 -21
  3. package/README.md +6 -4
  4. package/installer/cli.js +4 -1
  5. package/package.json +2 -2
  6. package/packages/ampcode/commands/live-canvas/README.md +38 -47
  7. package/packages/{claude/skills/live-canvas/templates/demo → ampcode/commands/live-canvas/dev}/post-variants.html +8 -3
  8. package/packages/ampcode/commands/live-canvas/templates/lab-banner.html +6 -0
  9. package/packages/ampcode/commands/live-canvas/templates/overlay-vanilla.js +64 -6
  10. package/packages/ampcode/commands/live-canvas.md +140 -161
  11. package/packages/claude/plugins/live-canvas-marketplace/plugins/live-canvas-channel/.claude-plugin/plugin.json +1 -1
  12. package/packages/claude/plugins/live-canvas-marketplace/plugins/live-canvas-channel/README.md +2 -2
  13. package/packages/claude/plugins/live-canvas-marketplace/plugins/live-canvas-channel/package-lock.json +13 -13
  14. package/packages/claude/plugins/live-canvas-marketplace/plugins/live-canvas-channel/package.json +1 -1
  15. package/packages/claude/plugins/live-canvas-marketplace/plugins/live-canvas-channel/schema.json +1 -1
  16. package/packages/claude/plugins/live-canvas-marketplace/plugins/live-canvas-channel/server.js +320 -27
  17. package/packages/claude/plugins/live-canvas-marketplace/setup.sh +83 -24
  18. package/packages/claude/skills/live-canvas/README.md +53 -57
  19. package/packages/claude/skills/live-canvas/SKILL.md +122 -158
  20. package/packages/{ampcode/commands/live-canvas/templates/demo → claude/skills/live-canvas/dev}/post-variants.html +8 -3
  21. package/packages/claude/skills/live-canvas/templates/lab-banner.html +6 -0
  22. package/packages/claude/skills/live-canvas/templates/overlay-vanilla.js +64 -6
  23. package/packages/droid/commands/live-canvas/README.md +38 -47
  24. package/packages/droid/commands/live-canvas/{templates/demo → dev}/post-variants.html +8 -3
  25. package/packages/droid/commands/live-canvas/templates/lab-banner.html +6 -0
  26. package/packages/droid/commands/live-canvas/templates/overlay-vanilla.js +64 -6
  27. package/packages/droid/commands/live-canvas.md +140 -161
  28. package/packages/opencode/command/live-canvas/README.md +38 -47
  29. package/packages/opencode/command/live-canvas/{templates/demo → dev}/post-variants.html +8 -3
  30. package/packages/opencode/command/live-canvas/templates/lab-banner.html +6 -0
  31. package/packages/opencode/command/live-canvas/templates/overlay-vanilla.js +64 -6
  32. package/packages/opencode/command/live-canvas.md +140 -161
  33. package/packages/ampcode/commands/live-canvas/templates/.claude/settings.local.json +0 -8
  34. package/packages/ampcode/commands/live-canvas/templates/feedback-react/FeedbackOverlay.tsx +0 -1299
  35. package/packages/ampcode/commands/live-canvas/templates/feedback-react/format-utils.ts +0 -283
  36. package/packages/ampcode/commands/live-canvas/templates/feedback-react/index.ts +0 -62
  37. package/packages/ampcode/commands/live-canvas/templates/feedback-react/selector-utils.ts +0 -423
  38. package/packages/ampcode/commands/live-canvas/templates/feedback-react/types.ts +0 -118
  39. package/packages/claude/skills/live-canvas/INTEGRATION_NOTES.md +0 -210
  40. package/packages/claude/skills/live-canvas/templates/.claude/settings.local.json +0 -8
  41. package/packages/claude/skills/live-canvas/templates/feedback-react/FeedbackOverlay.tsx +0 -1299
  42. package/packages/claude/skills/live-canvas/templates/feedback-react/format-utils.ts +0 -283
  43. package/packages/claude/skills/live-canvas/templates/feedback-react/index.ts +0 -62
  44. package/packages/claude/skills/live-canvas/templates/feedback-react/selector-utils.ts +0 -423
  45. package/packages/claude/skills/live-canvas/templates/feedback-react/types.ts +0 -118
  46. package/packages/droid/commands/live-canvas/templates/.claude/settings.local.json +0 -8
  47. package/packages/droid/commands/live-canvas/templates/feedback-react/FeedbackOverlay.tsx +0 -1299
  48. package/packages/droid/commands/live-canvas/templates/feedback-react/format-utils.ts +0 -283
  49. package/packages/droid/commands/live-canvas/templates/feedback-react/index.ts +0 -62
  50. package/packages/droid/commands/live-canvas/templates/feedback-react/selector-utils.ts +0 -423
  51. package/packages/droid/commands/live-canvas/templates/feedback-react/types.ts +0 -118
  52. package/packages/opencode/command/live-canvas/templates/.claude/settings.local.json +0 -8
  53. package/packages/opencode/command/live-canvas/templates/feedback-react/FeedbackOverlay.tsx +0 -1299
  54. package/packages/opencode/command/live-canvas/templates/feedback-react/format-utils.ts +0 -283
  55. package/packages/opencode/command/live-canvas/templates/feedback-react/index.ts +0 -62
  56. package/packages/opencode/command/live-canvas/templates/feedback-react/selector-utils.ts +0 -423
  57. package/packages/opencode/command/live-canvas/templates/feedback-react/types.ts +0 -118
@@ -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`);
@@ -1,61 +1,120 @@
1
1
  #!/usr/bin/env bash
2
- # Live Canvas channel — one-time setup helper.
2
+ # Live Canvas channel — one-time setup.
3
3
  #
4
- # Run this once from a shell, then follow the printed manual steps in Claude Code.
5
- # Safe to re-run; `npm install` is idempotent.
4
+ # Copies the marketplace into ~/.claude/plugins/, installs the channel
5
+ # plugin's npm deps, and adds a `live-claude` shell function so the user
6
+ # can launch a Live-mode session with one word in a new terminal.
7
+ #
8
+ # Safe to re-run: existing install at ~/.claude/plugins/live-canvas-marketplace/
9
+ # is overwritten; the shell function is replaced in place via marker guards.
6
10
 
7
11
  set -e
12
+
8
13
  HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
9
- PLUGIN_DIR="$HERE/plugins/live-canvas-channel"
10
- MARKETPLACE_PATH="$HERE"
14
+ DEST_ROOT="${HOME}/.claude/plugins"
15
+ DEST="${DEST_ROOT}/live-canvas-marketplace"
16
+ PLUGIN_DIR="${DEST}/plugins/live-canvas-channel"
17
+ MARKER_BEGIN="# >>> live-canvas: live-claude function (managed by setup.sh) >>>"
18
+ MARKER_END="# <<< live-canvas: live-claude function <<<"
11
19
 
12
20
  bold() { printf '\033[1m%s\033[0m\n' "$*"; }
13
21
  ok() { printf '\033[32m✓\033[0m %s\n' "$*"; }
14
22
  warn() { printf '\033[33m!\033[0m %s\n' "$*"; }
23
+ err() { printf '\033[31m✗\033[0m %s\n' "$*"; }
15
24
 
16
- if ! command -v node >/dev/null 2>&1; then
17
- warn "Node.js not found. Install Node >= 18 first: https://nodejs.org"
25
+ # ---------- prereq checks ----------
26
+
27
+ if [ "$(id -u)" = "0" ] && [ -n "${SUDO_USER:-}" ]; then
28
+ err "Don't run setup.sh with sudo — it would install into root's home (\$HOME=$HOME)."
29
+ err "Rerun as your normal user: bash $0"
18
30
  exit 1
19
31
  fi
20
32
 
33
+ if ! command -v node >/dev/null 2>&1; then
34
+ err "Node.js not found. Install Node >= 18: https://nodejs.org"
35
+ exit 1
36
+ fi
21
37
  NODE_MAJOR="$(node -p 'process.versions.node.split(".")[0]')"
22
38
  if [ "$NODE_MAJOR" -lt 18 ]; then
23
- warn "Node >= 18 required (found $(node -v))."
39
+ err "Node >= 18 required (found $(node -v))."
24
40
  exit 1
25
41
  fi
26
-
27
42
  if ! command -v npm >/dev/null 2>&1; then
28
- warn "npm not found. Usually ships with Node."
43
+ err "npm not found. Usually ships with Node."
29
44
  exit 1
30
45
  fi
31
46
 
47
+ # ---------- 1. copy marketplace into ~/.claude/plugins ----------
48
+
49
+ bold "Installing marketplace to ${DEST}…"
50
+ mkdir -p "$DEST_ROOT"
51
+ # Overwrite any prior install. Small, self-contained tree — safe to wipe.
52
+ rm -rf "$DEST"
53
+ cp -R "$HERE" "$DEST"
54
+ ok "Copied marketplace files."
55
+
56
+ # ---------- 2. npm install channel plugin deps ----------
57
+
32
58
  bold "Installing channel plugin dependencies…"
33
59
  cd "$PLUGIN_DIR"
34
60
  npm install --silent
35
61
  ok "Dependencies installed in $PLUGIN_DIR"
36
62
 
37
- echo
38
- bold "Next steps (run these inside Claude Code):"
63
+ # ---------- 3. add live-claude function to shell rc files ----------
64
+
65
+ add_to_rc() {
66
+ local rc="$1"
67
+ [ -e "$rc" ] || return 0
68
+
69
+ local tmp
70
+ tmp="$(mktemp)"
71
+ # Strip any prior managed block, preserving the rest of the file verbatim.
72
+ awk -v b="$MARKER_BEGIN" -v e="$MARKER_END" '
73
+ $0 == b { skip = 1; next }
74
+ $0 == e { skip = 0; next }
75
+ !skip { print }
76
+ ' "$rc" > "$tmp"
77
+
78
+ {
79
+ cat "$tmp"
80
+ echo ""
81
+ echo "$MARKER_BEGIN"
82
+ echo "live-claude() {"
83
+ echo " claude --dangerously-load-development-channels plugin:live-canvas-channel@live-canvas-marketplace \"\$@\""
84
+ echo "}"
85
+ echo "$MARKER_END"
86
+ } > "$rc.live-canvas.tmp"
87
+
88
+ mv "$rc.live-canvas.tmp" "$rc"
89
+ rm -f "$tmp"
90
+ ok "Updated $rc"
91
+ }
92
+
93
+ bold "Adding live-claude function to your shell rc files…"
94
+ add_to_rc "$HOME/.zshrc"
95
+ add_to_rc "$HOME/.bashrc"
96
+
97
+ # ---------- 4. print remaining manual steps ----------
98
+
39
99
  cat <<EOF
40
100
 
41
- 1. Register this marketplace:
42
- /plugin marketplace add $MARKETPLACE_PATH
101
+ $(bold "Two more steps — run these inside Claude Code:")
102
+
103
+ 1. Register the marketplace:
104
+ /plugin marketplace add ${DEST}
43
105
 
44
106
  2. Install the plugin from it:
45
107
  /plugin install live-canvas-channel@live-canvas-marketplace
46
108
 
47
- 3. Close this session and start a new one with the dev-channels flag:
48
- claude --dangerously-load-development-channels plugin:live-canvas-channel@live-canvas-marketplace
109
+ $(bold "Then daily use:")
49
110
 
50
- 4. Confirm the one-time safety prompt. Channel is now listening on
51
- port 8788 for overlay feedback events.
111
+ Open a fresh shell (or: source ~/.zshrc) so live-claude is on PATH.
112
+ Terminal A your dev server (e.g. npm run dev).
113
+ • Terminal B — run: live-claude
114
+ (this is just: claude --dangerously-load-development-channels …)
115
+ • Inside that Claude session, run /live-canvas.
52
116
 
53
- 5. Run /live-canvas to generate variants in any project. The skill will
54
- auto-detect the channel and use Live mode.
55
-
56
- Tip — save yourself typing with a shell alias in ~/.zshrc or ~/.bashrc:
57
- alias claude-live='claude --dangerously-load-development-channels plugin:live-canvas-channel@live-canvas-marketplace'
117
+ You'll be asked Live vs JSON mode each time.
58
118
 
59
119
  EOF
60
-
61
120
  ok "Setup complete."