dsh-comfyui-canvas 0.1.1 → 0.1.2

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.
package/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  > [中文](README.zh.md) · English
4
4
 
5
5
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
6
- [![Version](https://img.shields.io/badge/version-0.1.1-brightgreen.svg)](https://github.com/wbin0001/dsh-comfyui-canvas/releases)
6
+ [![Version](https://img.shields.io/badge/version-0.1.2-brightgreen.svg)](https://github.com/wbin0001/dsh-comfyui-canvas/releases)
7
7
  [![GitHub Stars](https://img.shields.io/github/stars/wbin0001/dsh-comfyui-canvas.svg?style=social)](https://github.com/wbin0001/dsh-comfyui-canvas)
8
8
  [![DSH](https://img.shields.io/badge/DeepSeek_Harness-compatible-blueviolet.svg)](https://github.com/DeepSeek-Harness/DSH)
9
9
  [![ComfyUI](https://img.shields.io/badge/ComfyUI-0.34+-orange.svg)](https://github.com/comfyanonymous/ComfyUI)
@@ -19,6 +19,8 @@ Embed your **ComfyUI** (local or cloud) as a split-screen canvas tab inside [Dee
19
19
 
20
20
  This package is the DSH-side plugin, and it ships the ComfyUI-side bridge node too. For headless/scale workloads it can be paired with the official ComfyUI MCP server — see [Canvas vs MCP](#canvas-vs-mcp--two-ways-to-drive-comfyui).
21
21
 
22
+ > **Where this plugin fits**: use it while **building / tuning a workflow on the live canvas** (the "IDE" role). For **unattended / batch / production runs**, hand the exported workflow to **Comfy CLI** (`comfy-cli run_workflow`) — it runs headlessly without a browser, which this canvas plugin deliberately does not do (the agent drives the canvas you are looking at; a closed browser means no runner). Export a workflow once with `comfyui_export_api`, then script it with the CLI at scale.
23
+
22
24
  ## What you get
23
25
 
24
26
  | Surface | Description |
@@ -26,7 +28,8 @@ This package is the DSH-side plugin, and it ships the ComfyUI-side bridge node t
26
28
  | **ComfyUI canvas tab** | A `ComfyUI` conversation view that embeds the ComfyUI frontend (local or cloud) side by side with the Chat rail. The iframe stays alive across tab switches (no reload). |
27
29
  | **Visual canvas copilot** | The agent operates **the canvas you are looking at** — nodes appear, links wire, widgets change and runs trigger live on screen, so you watch every step instead of trusting an opaque JSON edit. Output images come back into the chat via `comfyui_get_outputs`. |
28
30
  | **Canvas ops tools** | `comfyui_read_workflow`, `add_node`, `connect`, `set_param`, `remove_node`, `inject_text`, `load_workflow`, `run`, `debug` — build and fix workflows on the live canvas; `inject_text` writes conversation text straight into a node or a new wirable source. |
29
- | **Production tools** | `comfyui_batch_run` sweeps a parameter matrix (seeds/prompts/strengths) in one go; `comfyui_get_outputs` pulls the resulting images back into the chat; `comfyui_attach_image` uploads a local image into ComfyUI's input/ for a LoadImage node; `comfyui_export_api` exports the live canvas as API-format workflow JSON for comfy-cli headless batch runs. |
31
+ | **Production tools** | `comfyui_batch_run` sweeps a parameter matrix (seeds/prompts/strengths) in one go; `comfyui_get_outputs` pulls the resulting files back into the chat — images, videos, gifs, and audio; `comfyui_attach_file` uploads any local file (image/audio/video/3D) into ComfyUI's input/ for the matching Load node; `comfyui_export_api` exports the live canvas as API-format workflow JSON for comfy-cli headless batch runs. |
32
+ | **Skills (SOPs)** | Built-in skills teach the agent the right order of operations: `comfyui-canvas-ops` (read → confirm → edit → run → fetch → self-check), `comfyui-admin-ops` (configure/launch/upgrade/node management), and `comfyui-video-audio-ops` (video + voiceover/audio track). Install the plugin and the skills ship with it — no extra setup. |
30
33
  | **Upkeep tool** | `comfyui_upgrade` one-click updates the ComfyUI core and every git-backed custom node; `comfyui_config` reports the active connection and canvas focus. |
31
34
  | **Canvas focus mode** | The agent can tell (via `comfyui_config`) whether the browser is on the canvas tab for the current session, and focus on canvas work only then. Session-isolated. |
32
35
  | **Settings page** | ComfyUI base URL / port / network mode / bridge token / launch command / rail width. Changes apply live. Customized nav icon with ComfyUI logo. |
@@ -37,10 +40,16 @@ This package is the DSH-side plugin, and it ships the ComfyUI-side bridge node t
37
40
  ### 1. Install the DSH plugin
38
41
 
39
42
  ```bash
40
- dsh plugin add github:<your-name>/dsh-comfyui-canvas
43
+ dsh plugin add dsh-comfyui-canvas
44
+ ```
45
+
46
+ Or from a GitHub checkout:
47
+
48
+ ```bash
49
+ dsh plugin add github:wbin0001/dsh-comfyui-canvas
41
50
  ```
42
51
 
43
- or from a local checkout:
52
+ Or from a local checkout:
44
53
 
45
54
  ```bash
46
55
  # in your DSH profile
@@ -166,7 +175,7 @@ dsh-comfyui-canvas/
166
175
  │ ├── __init__.py # /dsh-bridge/* HTTP routes on the ComfyUI server
167
176
  │ └── entry/bridge.js # injected frontend: reports graph + runs commands
168
177
  ├── lib/
169
- │ ├── index.js # DSH host: 15 canvas tools + session-isolated mode
178
+ │ ├── index.js # DSH host: 19 canvas tools + 4 built-in skills
170
179
  │ └── client.js # DSH web: canvas tab / settings / rail polish
171
180
  ├── LICENSE
172
181
  ├── README.md
package/README.zh.md CHANGED
@@ -3,7 +3,7 @@
3
3
  > 中文 · [English](README.md)
4
4
 
5
5
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
6
- [![Version](https://img.shields.io/badge/version-0.1.1-brightgreen.svg)](https://github.com/wbin0001/dsh-comfyui-canvas/releases)
6
+ [![Version](https://img.shields.io/badge/version-0.1.2-brightgreen.svg)](https://github.com/wbin0001/dsh-comfyui-canvas/releases)
7
7
  [![GitHub Stars](https://img.shields.io/github/stars/wbin0001/dsh-comfyui-canvas.svg?style=social)](https://github.com/wbin0001/dsh-comfyui-canvas)
8
8
  [![DSH](https://img.shields.io/badge/DeepSeek_Harness-compatible-blueviolet.svg)](https://github.com/DeepSeek-Harness/DSH)
9
9
  [![ComfyUI](https://img.shields.io/badge/ComfyUI-0.34+-orange.svg)](https://github.com/comfyanonymous/ComfyUI)
@@ -28,7 +28,8 @@
28
28
  | **画布标签页** | 对话里新增 `ComfyUI` 标签,左边画布、右边对话 rail 分屏。iframe 常驻不重载,切标签秒回。 |
29
29
  | **可视化画布副驾** | agent 操作**你正在看的画布**——节点出现、连线接上、参数变化、运行触发,全部实时显示在屏幕上,每一步都看得见,而不是黑盒改 JSON。出图经 `comfyui_get_outputs` 直接带回对话。 |
30
30
  | **画布操作工具** | `comfyui_read_workflow` / `add_node` / `connect` / `set_param` / `remove_node` / `inject_text` / `load_workflow` / `run` / `debug`——在活画布上搭建与修复工作流;`inject_text` 把对话文本一步注入为可连线节点。 |
31
- | **生产工具** | `comfyui_batch_run` 一次扫参数矩阵(seed / prompt / 强度);`comfyui_get_outputs` 把出图直接带回对话;`comfyui_attach_image` 把对话里的图片送进画布 LoadImage;`comfyui_export_api` 把当前画布导出为 API 格式工作流,供 comfy-cli 无人值守批量。 |
31
+ | **生产工具** | `comfyui_batch_run` 一次扫参数矩阵(seed / prompt / 强度);`comfyui_get_outputs` 把产物直接带回对话——图像、视频、GIF、音频都支持;`comfyui_attach_file` 把本机任意文件(图片/音频/视频/3D)上传进 ComfyUI `input/` 供对应 Load 节点使用;`comfyui_export_api` 把当前画布导出为 API 格式工作流,供 comfy-cli 无人值守批量。 |
32
+ | **技能包(SOP)** | 内置技能教 agent 按正确顺序操作:`comfyui-canvas-ops`(读→确认→改→跑→取回→自检)、`comfyui-admin-ops`(配置/启动/升级/节点管理)、`comfyui-video-audio-ops`(视频+配音/音轨)。装插件即自带技能,无需额外配置。 |
32
33
  | **维护工具** | `comfyui_upgrade` 一键升级 ComfyUI 核心与全部 git 自定义节点;`comfyui_config` 报告当前连接与画布专注状态。 |
33
34
  | **画布专注模式(会话隔离)** | agent 通过 `comfyui_config` 感知当前会话是否在画布标签,只在画布场景专注画布操作,且**按会话隔离**——多个会话互不干扰。 |
34
35
  | **ComfyUI 报错处理** | `debug` 校验工作流并高亮报错节点(纯校验,不触发执行),agent 帮你定位/修复画布错误。 |
@@ -42,7 +43,13 @@
42
43
  ### 1. 安装 DSH 插件
43
44
 
44
45
  ```bash
45
- dsh plugin add github:<你的用户名>/dsh-comfyui-canvas
46
+ dsh plugin add dsh-comfyui-canvas
47
+ ```
48
+
49
+ 或从 GitHub 安装:
50
+
51
+ ```bash
52
+ dsh plugin add github:wbin0001/dsh-comfyui-canvas
46
53
  ```
47
54
 
48
55
  或本地安装:
@@ -249,10 +249,29 @@ step 12 发布(npm publish + awesome-dsh-plugin 收录信息若有变更)
249
249
  ### 设置导航图标(核心改动 §5.5)已落地
250
250
  - 见 §5.5 完整记录;核心已还原、非侵入式图标生效、已推送
251
251
 
252
- ### npm publish(待办,见 §4 step 12)
253
- - 包名 `dsh-comfyui-canvas` 在 npm **未被占用**(此前确认)
254
- - `package.json` `files` 已覆盖 LICENSE/README/lib/comfyui-bridge/screenshots.json/docs
255
- - 待执行:`npm config set registry https://registry.npmjs.org` → `npm publish`
252
+ ### npm publish 已完成(2026-09-02)
253
+ - ✅ `dsh-comfyui-canvas@0.1.1` 已发布到官方 registry(https://www.npmjs.com/package/dsh-comfyui-canvas)
254
+ - 包名未被占用;`files` 覆盖 LICENSE/README/lib/comfyui-bridge/screenshots.json/docs
255
+ - 安装命令已更新为 `dsh plugin add dsh-comfyui-canvas`(npm 方式)
256
+ - 发布 SOP:改版本号 → `npm config set registry https://registry.npmjs.org` → `npm publish`
257
+ - ⚠️ 注意:账户已开 2FA(安全密钥/Passkey);发布用 granular token(bypass 2FA,90 天有效,约 2026-12 到期)
258
+
259
+ ### 通用文件传输 A1 + A2 已实现(2026-09-02,`a73ffd4`)
260
+ - ✅ `comfyui_attach_file`(host `lib/index.js`):任意文件(image/audio/video/3D)上传进 ComfyUI `input/`,`mediaTypeOf` 按扩展名推断,可选 `nodeId`+`widgetKey` 指向 Load 节点
261
+ - ✅ `comfyui_get_outputs` 泛化:扫描 `images/videos/gifs/audio` 四种 kind,每条带 `kind` + `/view` URL
262
+ - 已同步 node_modules 副本、已推送 GitHub
263
+
264
+ ### 技能包 B 已实现(2026-09-02,`d1c3d07`)
265
+ - ✅ `ctx.skills.register` 内嵌三个 runtime 技能(随插件分发、零步骤):
266
+ - `comfyui-canvas-ops`(画布操作 SOP:读→确认→改→跑→取回→自检)
267
+ - `comfyui-admin-ops`(环境管理 SOP:配置/启动/升级/节点管理)
268
+ - `comfyui-video-audio-ops`(音画创作 SOP:视频+配音/音轨)
269
+ - `inject` 加 `skills`;技能内容中英双语、含安全约定
270
+ - 已同步 node_modules 副本、已推送 GitHub
271
+
272
+ ### 剩余(见 §4 实施步骤)
273
+ - A3 画布会话框「+ 文件」通道(client 侧,硬前置:浏览器路径传递)
274
+ - C 节点开发调试(`read_source` / `edit_source` / `reload` + `comfyui-dev-ops` 技能)
256
275
 
257
276
  ---
258
277
 
package/lib/client.js CHANGED
@@ -24,6 +24,9 @@ function canvasState() {
24
24
 
25
25
  // 按会话更新 activeViewBySession 映射(读-改-写整张 map,settings 写入串行安全)。
26
26
  // 只改自己 sessionId 的键,其它会话不受影响 → 画布模式感知是会话隔离的。
27
+ // 上限 MAX_SESSION_VIEWS:会话销毁时不会自动清理,若不封顶 map 会无限增长
28
+ // (WKB review 实测已 7 条)。写时裁剪到「当前会话 + 最近的 N-1 条」。
29
+ var MAX_SESSION_VIEWS = 50;
27
30
  function setSessionView(sc, sessionId, mode) {
28
31
  var snap = sc.getSnapshot ? sc.getSnapshot() : null;
29
32
  var value = snap && snap.value ? snap.value : {};
@@ -33,6 +36,23 @@ function setSessionView(sc, sessionId, mode) {
33
36
  for (var k in current) map[k] = current[k];
34
37
  }
35
38
  map[String(sessionId)] = mode;
39
+ var keys = Object.keys(map);
40
+ if (keys.length > MAX_SESSION_VIEWS) {
41
+ // Drop oldest keys (object string-key order = insertion order), always
42
+ // keeping the current session.
43
+ var drop = keys.length - MAX_SESSION_VIEWS;
44
+ var keep = {};
45
+ var currentKey = String(sessionId);
46
+ var kept = 0;
47
+ for (var i = 0; i < keys.length; i++) {
48
+ var k = keys[i];
49
+ if (k === currentKey || kept < MAX_SESSION_VIEWS - 1) {
50
+ keep[k] = map[k];
51
+ kept++;
52
+ }
53
+ }
54
+ map = keep;
55
+ }
36
56
  sc.set("activeViewBySession", map).catch(function () {});
37
57
  }
38
58
 
@@ -562,9 +582,10 @@ function ComfyUICanvasView(props) {
562
582
  };
563
583
  }, [sessionId]);
564
584
 
565
- // ComfyUI 可达性检测:每隔几秒探一下。注意用 mode:'no-cors' —— DSH 页面
566
- // (127.0.0.1:3080) 跨域探测 ComfyUI (127.0.0.1:8188) 会被 CORS 拦截,no-cors
567
- // 只要服务可达就 resolve(opaque),不可达才 reject,适合纯可达性探测。
585
+ // ComfyUI 可达性检测:每隔几秒探一下。优先用正常 CORS 请求并检查 res.ok
586
+ // (能区分"在线"和"500 报错");若 DSH 页面 (127.0.0.1:3080) 跨域探测
587
+ // ComfyUI (127.0.0.1:8188) 被 CORS 拦截(抛 TypeError),回退到 mode:'no-cors'
588
+ // 的 opaque 可达性探测——服务可达即 resolve,不可达才 reject。
568
589
  var onlineState = React.useState(null); // null=检测中, true=在线, false=离线
569
590
  var online = onlineState[0];
570
591
  var setOnline = onlineState[1];
@@ -574,17 +595,28 @@ function ComfyUICanvasView(props) {
574
595
  // 同步顶栏状态圆点(初始检测中)。
575
596
  updateCanvasStatus(null);
576
597
  var probe = function () {
577
- fetch(activeBase + "/system_stats", { method: "GET", mode: "no-cors", signal: AbortSignal.timeout(2500) })
578
- .then(function () {
598
+ fetch(activeBase + "/system_stats", { method: "GET", signal: AbortSignal.timeout(2500) })
599
+ .then(function (res) {
579
600
  if (stopped) return;
580
- setOnline(true);
581
- setStarting(false); // 服务已可达,清掉"正在启动"态
582
- updateCanvasStatus(true);
601
+ setOnline(res.ok); // 200-299 → 在线;4xx/5xx → 离线(服务在但报错)
602
+ setStarting(false);
603
+ updateCanvasStatus(res.ok);
583
604
  })
584
605
  .catch(function () {
585
606
  if (stopped) return;
586
- setOnline(false);
587
- updateCanvasStatus(false);
607
+ // CORS 拦截(TypeError)或网络错误 → 用 no-cors 回退判断端口是否可达
608
+ fetch(activeBase + "/system_stats", { method: "GET", mode: "no-cors", signal: AbortSignal.timeout(2500) })
609
+ .then(function () {
610
+ if (stopped) return;
611
+ setOnline(true);
612
+ setStarting(false);
613
+ updateCanvasStatus(true);
614
+ })
615
+ .catch(function () {
616
+ if (stopped) return;
617
+ setOnline(false);
618
+ updateCanvasStatus(false);
619
+ });
588
620
  });
589
621
  };
590
622
  probe();
package/lib/index.js CHANGED
@@ -17,7 +17,7 @@ import z from '@deepseek-ai/schemastery'
17
17
  import { settingsNamespace } from '@deepseek-ai/dsh-settings'
18
18
 
19
19
  export const name = 'dsh-comfyui-canvas'
20
- export const inject = ['tools', 'settings', 'shell']
20
+ export const inject = ['tools', 'settings', 'shell', 'skills']
21
21
 
22
22
  const NAMESPACE = settingsNamespace('dsh-comfyui-canvas')
23
23
  const DEFAULT_BASE = 'http://127.0.0.1:8188'
@@ -40,6 +40,10 @@ const ConfigSchema = z.object({
40
40
  comfyuiDir: z.string().default(''),
41
41
  /** Split-rail width in px. */
42
42
  railWidth: z.number().default(360),
43
+ /** Canvas scale percent (legacy UI field; kept in schema so settings don't drop it). */
44
+ canvasScale: z.number().default(80),
45
+ /** Last active view across sessions (legacy echo of activeViewBySession). */
46
+ activeView: z.string().default('chat'),
43
47
  /**
44
48
  * Optional shared secret for the ComfyUI bridge. When set, every agent
45
49
  * request carries `Authorization: Bearer <token>`, and the bridge only
@@ -236,7 +240,38 @@ export function apply(ctx) {
236
240
  updated_at: null,
237
241
  }
238
242
  }
239
- return { ready: true, ...data }
243
+ // Normalize node/link ids to numbers. The frontend reports ids as
244
+ // strings; every write tool (connect srcId/dstId, set_param nodeId, …)
245
+ // declares `number`, so returning strings forces the model to convert.
246
+ // Coerce only numeric-looking ids; leave exotic ones untouched.
247
+ const normalized = {
248
+ ...data,
249
+ nodes: Array.isArray(data.nodes) ? data.nodes.map((n) => {
250
+ const id = Number(n?.id)
251
+ return { ...n, id: Number.isFinite(id) ? id : n?.id }
252
+ }) : data.nodes,
253
+ workflow: data.workflow && Array.isArray(data.workflow.nodes)
254
+ ? {
255
+ ...data.workflow,
256
+ nodes: data.workflow.nodes.map((n) => {
257
+ const id = Number(n?.id)
258
+ return { ...n, id: Number.isFinite(id) ? id : n?.id }
259
+ }),
260
+ links: Array.isArray(data.workflow.links) ? data.workflow.links.map((l) => {
261
+ const src = Number(l?.[1])
262
+ const dst = Number(l?.[3])
263
+ return [
264
+ l?.[0],
265
+ Number.isFinite(src) ? src : l?.[1],
266
+ l?.[2],
267
+ Number.isFinite(dst) ? dst : l?.[3],
268
+ ...(l?.slice ? l.slice(4) : []),
269
+ ]
270
+ }) : data.workflow.links,
271
+ }
272
+ : data.workflow,
273
+ }
274
+ return { ready: true, ...normalized }
240
275
  },
241
276
  }))
242
277
 
@@ -360,19 +395,20 @@ export function apply(ctx) {
360
395
  },
361
396
  }))
362
397
 
363
- // M2: fetch the output images of a completed run. Reads ComfyUI's own
364
- // /history/{promptId} (native endpoint, same trust model as /prompt) and
365
- // lists every output node's images (filename / subfolder / type / view URL).
366
- // With downloadDir, the images are also saved locally. `ready:false` means
367
- // the id is not in history yet — the run may still be executing.
398
+ // M2: fetch the output files of a completed run (images, videos, gifs,
399
+ // audio). Reads ComfyUI's own /history/{promptId} (native endpoint, same
400
+ // trust model as /prompt) and lists every output node's files (kind /
401
+ // filename / subfolder / type / view URL). With downloadDir, the files are
402
+ // also saved locally. `ready:false` means the id is not in history yet.
368
403
  ctx.tools.register(defineTool({
369
404
  name: 'comfyui_get_outputs',
370
- description: 'Fetch the output images produced by a completed ComfyUI run (comfyui_run returns prompt_id). ' +
371
- 'Reads ComfyUI /history/{promptId}, lists every output node with its images (filename/type/subfolder/URL), ' +
372
- 'and optionally downloads them into a local directory. Returns ready:false while the run is still executing.',
405
+ description: 'Fetch the output files produced by a completed ComfyUI run (comfyui_run returns prompt_id): ' +
406
+ 'images, videos, gifs, and audio. Reads ComfyUI /history/{promptId}, lists every output node with its ' +
407
+ 'files (kind/filename/type/subfolder/URL), and optionally downloads them into a local directory. ' +
408
+ 'Returns ready:false while the run is still executing.',
373
409
  parameters: {
374
410
  promptId: { type: 'string', required: true, description: 'The prompt_id returned by comfyui_run.' },
375
- downloadDir: { type: 'string', description: 'Optional absolute local directory to save the images into. Omit to return URLs only.' },
411
+ downloadDir: { type: 'string', description: 'Optional absolute local directory to save the output files into. Omit to return URLs only.' },
376
412
  },
377
413
  output: { schema: { type: 'object', additionalProperties: true }, render: renderJSON },
378
414
  async execute(args, exec) {
@@ -387,17 +423,27 @@ export function apply(ctx) {
387
423
  }
388
424
  const outputs = item.outputs ?? {}
389
425
  const files = []
426
+ // ComfyUI history outputs carry file arrays under per-kind keys:
427
+ // images / videos / gifs / audio (depends on the node). Scan them all so
428
+ // a run's video/audio/3D outputs come back like images do — each entry
429
+ // keeps its kind + a /view URL (the view endpoint serves any stored file).
430
+ const FILE_KINDS = ['images', 'videos', 'gifs', 'audio']
431
+ // Singular label per history key (images→image, videos→video, gifs→gif,
432
+ // audio→audio). A naive slice(0,-1) would corrupt "audio" into "audi".
433
+ const KIND_LABEL = { images: 'image', videos: 'video', gifs: 'gif', audio: 'audio' }
390
434
  for (const [nodeId, out] of Object.entries(outputs)) {
391
- const images = Array.isArray(out?.images) ? out.images : []
392
- for (const img of images) {
393
- const filename = img?.filename
394
- if (!filename) continue
395
- const subfolder = img?.subfolder ?? ''
396
- const type = img?.type ?? 'output'
397
- const qs = `filename=${encodeURIComponent(filename)}` +
398
- (subfolder ? `&subfolder=${encodeURIComponent(subfolder)}` : '') +
399
- `&type=${encodeURIComponent(type)}`
400
- files.push({ nodeId, filename, subfolder, type, url: `${base}/view?${qs}` })
435
+ for (const kind of FILE_KINDS) {
436
+ const list = Array.isArray(out?.[kind]) ? out[kind] : []
437
+ for (const file of list) {
438
+ const filename = file?.filename
439
+ if (!filename) continue
440
+ const subfolder = file?.subfolder ?? ''
441
+ const type = file?.type ?? 'output'
442
+ const qs = `filename=${encodeURIComponent(filename)}` +
443
+ (subfolder ? `&subfolder=${encodeURIComponent(subfolder)}` : '') +
444
+ `&type=${encodeURIComponent(type)}`
445
+ files.push({ nodeId, kind: KIND_LABEL[kind] ?? kind, filename, subfolder, type, url: `${base}/view?${qs}` })
446
+ }
401
447
  }
402
448
  }
403
449
  let downloaded = []
@@ -494,12 +540,48 @@ export function apply(ctx) {
494
540
  activeView = 'chat'
495
541
  activeViewKnown = true
496
542
  }
543
+ // P0 handshake: does the BRIDGE actually enforce the token? The DSH side
544
+ // setting alone proves nothing — if the ComfyUI side never got
545
+ // DSH_BRIDGE_TOKEN, _authorized() always passes and the bridge is open.
546
+ // Probe with the correct token and a deliberately wrong one: both
547
+ // succeeding means auth is NOT effective on the wire.
548
+ const token = (value?.bridgeToken || '').trim()
549
+ let bridgeAuthEffective = null
550
+ let bridgeAuthProbe = 'not-probed'
551
+ try {
552
+ const good = await fetch(`${baseUrl()}/dsh-bridge/workflow`, {
553
+ headers: token ? { Authorization: `Bearer ${token}` } : {},
554
+ signal: AbortSignal.timeout(4000),
555
+ })
556
+ const bad = token
557
+ ? await fetch(`${baseUrl()}/dsh-bridge/workflow`, {
558
+ headers: { Authorization: 'Bearer dsh-bt-__wrong-probe__' },
559
+ signal: AbortSignal.timeout(4000),
560
+ })
561
+ : good
562
+ if (!good.ok) {
563
+ bridgeAuthProbe = `unreachable-or-error: HTTP ${good.status}`
564
+ } else if (!token) {
565
+ // No token configured on either side — open by design but flag it.
566
+ bridgeAuthEffective = false
567
+ bridgeAuthProbe = 'no token configured on DSH side'
568
+ } else {
569
+ bridgeAuthEffective = bad.status === 401
570
+ bridgeAuthProbe = bad.status === 401
571
+ ? 'bridge rejects bad token (auth effective)'
572
+ : 'bridge accepted a WRONG token — auth NOT effective on the ComfyUI side'
573
+ }
574
+ } catch (e) {
575
+ bridgeAuthProbe = `probe failed: ${e instanceof Error ? e.message : String(e)}`
576
+ }
497
577
  return {
498
578
  baseUrl: baseUrl(),
499
579
  activeView,
500
580
  activeViewKnown,
501
581
  sessionSource,
502
- bridgeTokenSet: Boolean(value?.bridgeToken),
582
+ bridgeTokenSet: Boolean(token),
583
+ bridgeAuthEffective,
584
+ bridgeAuthProbe,
503
585
  comfyuiDir: value?.comfyuiDir || '',
504
586
  launchCommand: value?.launchCommand || '',
505
587
  }
@@ -529,6 +611,12 @@ export function apply(ctx) {
529
611
  }
530
612
  const fs = await import('node:fs')
531
613
  const path = await import('node:path')
614
+ // WKB review: 69 sequential git pulls can take ~2.3h and hang the agent.
615
+ // Run concurrently (4 lanes), bound the whole batch, and skip dirty
616
+ // checkouts (locally-modified repos are exactly this plugin's own users)
617
+ // instead of hard-pulling into conflicts.
618
+ const CONCURRENCY = 4
619
+ const BATCH_TIMEOUT_MS = 300000 // 5 min total
532
620
  const run = async (label, command, workdir) => {
533
621
  try {
534
622
  const spec = shell.resolve({ command, workdir, timeoutMs: 120000 })
@@ -542,9 +630,10 @@ export function apply(ctx) {
542
630
  }
543
631
  const results = []
544
632
  const scopeArg = args?.scope ?? 'all'
633
+ const jobs = []
545
634
  // core: the ComfyUI root is itself a git checkout
546
635
  if (scopeArg !== 'nodes' && fs.existsSync(path.join(dir, '.git'))) {
547
- results.push(await run('core', 'git pull', dir))
636
+ jobs.push({ label: 'core', workdir: dir })
548
637
  }
549
638
  // nodes: every git-backed custom node
550
639
  if (scopeArg !== 'core') {
@@ -557,12 +646,39 @@ export function apply(ctx) {
557
646
  for (const name of entries) {
558
647
  const nodeDir = path.join(nodesDir, name)
559
648
  if (fs.existsSync(path.join(nodeDir, '.git'))) {
560
- results.push(await run(`node:${name}`, 'git pull', nodeDir))
649
+ jobs.push({ label: `node:${name}`, workdir: nodeDir })
561
650
  }
562
651
  }
563
652
  }
564
653
  }
565
- return { upgraded: results.length, results }
654
+ // dirty probe: empty output = clean (pull safe); non-empty = skip + report
655
+ const skippedDirty = []
656
+ const pullJobs = []
657
+ for (const job of jobs) {
658
+ const probe = await run(job.label, 'git status --porcelain', job.workdir)
659
+ if (probe.ok && probe.out.trim().length > 0) {
660
+ skippedDirty.push({ label: job.label, changedFiles: probe.out.trim().split('\n').length })
661
+ } else {
662
+ pullJobs.push(job)
663
+ }
664
+ }
665
+ // bounded concurrency over the pull queue
666
+ const started = Date.now()
667
+ let cursor = 0
668
+ const worker = async () => {
669
+ while (cursor < pullJobs.length) {
670
+ if (Date.now() - started > BATCH_TIMEOUT_MS) {
671
+ results.push({ label: pullJobs[cursor].label, ok: false, error: 'batch timeout exceeded' })
672
+ cursor++
673
+ continue
674
+ }
675
+ const job = pullJobs[cursor++]
676
+ results.push(await run(job.label, 'git pull', job.workdir))
677
+ }
678
+ }
679
+ await Promise.all(Array.from({ length: Math.min(CONCURRENCY, pullJobs.length) }, worker))
680
+ results.sort((a, b) => a.label.localeCompare(b.label))
681
+ return { upgraded: results.length, skippedDirty, results }
566
682
  },
567
683
  }))
568
684
 
@@ -627,12 +743,14 @@ export function apply(ctx) {
627
743
  // the source file is on the agent's own machine, so the host reads and
628
744
  // uploads it — same trust model as get_outputs' /history + /view. The bridge
629
745
  // token does not apply (native endpoint, not /dsh-bridge/*).
746
+ // DEPRECATED (v0.1.2): superseded by comfyui_attach_file (image is a subset
747
+ // of its media types). Kept for back-compat; removed in a future version.
630
748
  ctx.tools.register(defineTool({
631
749
  name: 'comfyui_attach_image',
632
- description: 'Upload a local image into ComfyUI\'s input/ directory and optionally point a LoadImage ' +
633
- 'node at it. Uses ComfyUI\'s native /upload/image (not the bridge), so the source file is read from ' +
634
- 'the agent\'s own machine. After upload, pass nodeId to update a LoadImage node\'s image widget to ' +
635
- 'the new filename so it takes effect immediately.',
750
+ description: '[DEPRECATED — use comfyui_attach_file instead] Upload a local image into ComfyUI\'s input/ ' +
751
+ 'directory and optionally point a LoadImage node at it. Uses ComfyUI\'s native /upload/image (not the bridge), ' +
752
+ 'so the source file is read from the agent\'s own machine. After upload, pass nodeId to update a LoadImage ' +
753
+ 'node\'s image widget to the new filename so it takes effect immediately.',
636
754
  parameters: {
637
755
  image: { type: 'string', required: true, description: 'Absolute local path to the image file (png/jpeg/webp/gif).' },
638
756
  nodeId: { type: 'number', description: 'Optional LoadImage node id to point at the uploaded file.' },
@@ -723,4 +841,241 @@ export function apply(ctx) {
723
841
  return sendCommand(baseUrl(), bridgeToken(), 'export_api', {}, exec.signal, { timeoutMs: 10000 })
724
842
  },
725
843
  }))
844
+
845
+ // v0.1.2 C: node development/debug tools — read / edit custom-node source and
846
+ // reload ComfyUI so the change takes effect, all from the conversation. The
847
+ // canvas doubles as the observation window: edit → reload → run a minimal
848
+ // workflow on the canvas → see the result, without leaving the chat.
849
+ const requireComfyUIDir = async () => {
850
+ const value = settingsScope?.get?.()
851
+ const dir = (value?.comfyuiDir || process.env.COMFYUI_DIR || '').trim()
852
+ if (!dir) throw new Error('ComfyUI install directory not set. Set comfyuiDir in Settings → ComfyUI 画布, or env COMFYUI_DIR.')
853
+ return dir
854
+ }
855
+ const resolveNodeDir = async (root, nodeName) => {
856
+ const { join, resolve, sep } = await import('node:path')
857
+ const nodeDir = join(root, 'custom_nodes', nodeName)
858
+ // Anchor to the custom_nodes directory itself, not the ComfyUI root:
859
+ // a nodeName like "../x" would otherwise resolve inside root but outside
860
+ // custom_nodes and slip past a root-anchored prefix check.
861
+ const nodesReal = resolve(join(root, 'custom_nodes'))
862
+ const nodeReal = resolve(nodeDir)
863
+ if (!nodeReal.startsWith(nodesReal + sep)) throw new Error(`invalid node path: ${nodeName}`)
864
+ return nodeDir
865
+ }
866
+ // Shared helper: list source files under a node dir (top two levels, skipping
867
+ // .git / node_modules / __pycache__ / .venv), or read one file's content.
868
+ const inspectNodeSource = async (nodeDir, relPath) => {
869
+ const fs = await import('node:fs')
870
+ const path = await import('node:path')
871
+ if (relPath) {
872
+ const target = path.resolve(nodeDir, relPath)
873
+ if (!target.startsWith(path.resolve(nodeDir) + path.sep)) throw new Error('path escapes node dir')
874
+ if (!fs.existsSync(target) || !fs.statSync(target).isFile()) throw new Error(`file not found: ${relPath}`)
875
+ return { path: relPath, content: fs.readFileSync(target, 'utf8') }
876
+ }
877
+ const SKIP = new Set(['.git', 'node_modules', '__pycache__', '.venv', 'venv', 'dist', 'build'])
878
+ const tree = []
879
+ const walk = (dir, depth) => {
880
+ if (depth > 2) return
881
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
882
+ if (SKIP.has(entry.name)) continue
883
+ const full = path.join(dir, entry.name)
884
+ const rel = path.relative(nodeDir, full).replace(/\\/g, '/')
885
+ tree.push({ path: rel, type: entry.isDirectory() ? 'dir' : 'file', size: entry.isFile() ? fs.statSync(full).size : null })
886
+ if (entry.isDirectory()) walk(full, depth + 1)
887
+ }
888
+ }
889
+ walk(nodeDir, 0)
890
+ return { node: path.basename(nodeDir), tree }
891
+ }
892
+ ctx.tools.register(defineTool({
893
+ name: 'comfyui_read_source',
894
+ description: 'Read a ComfyUI custom node\'s source under custom_nodes/<name>/. Without path: list its source tree ' +
895
+ '(top two levels, skipping .git/node_modules/caches). With path: return that file\'s full content. ' +
896
+ 'Use during node development to inspect or debug a custom node (including this plugin\'s own bridge node).',
897
+ parameters: {
898
+ node: { type: 'string', required: true, description: 'Custom node directory name under ComfyUI custom_nodes/ (e.g. ComfyUI-DSH-Canvas).' },
899
+ path: { type: 'string', description: 'Optional relative file path inside the node dir to read (omit to list the tree).' },
900
+ },
901
+ output: { schema: { type: 'object', additionalProperties: true }, render: renderJSON },
902
+ async execute(args) {
903
+ const fs = await import('node:fs')
904
+ const dir = await requireComfyUIDir()
905
+ const nodeDir = await resolveNodeDir(dir, args.node)
906
+ if (!fs.existsSync(nodeDir)) throw new Error(`custom node not found: ${args.node}`)
907
+ return inspectNodeSource(nodeDir, args.path)
908
+ },
909
+ }))
910
+ ctx.tools.register(defineTool({
911
+ name: 'comfyui_edit_source',
912
+ description: 'Edit a file inside a ComfyUI custom node\'s source (custom_nodes/<name>/). Writes new content to the ' +
913
+ 'given relative path; path is confined to the node dir. Pair with comfyui_reload to apply the change: ' +
914
+ 'edit → reload → run a minimal workflow on the canvas to verify. This is how you develop/debug a custom node ' +
915
+ 'from the conversation without touching the ComfyUI UI.',
916
+ parameters: {
917
+ node: { type: 'string', required: true, description: 'Custom node directory name under ComfyUI custom_nodes/.' },
918
+ path: { type: 'string', required: true, description: 'Relative file path inside the node dir to write (e.g. __init__.py or entry/bridge.js).' },
919
+ content: { type: 'string', required: true, description: 'Full new file content (replaces the file).' },
920
+ },
921
+ output: { schema: { type: 'object', additionalProperties: true }, render: renderJSON },
922
+ async execute(args) {
923
+ const fs = await import('node:fs')
924
+ const path = await import('node:path')
925
+ const dir = await requireComfyUIDir()
926
+ const nodeDir = await resolveNodeDir(dir, args.node)
927
+ const target = path.resolve(nodeDir, args.path)
928
+ if (!target.startsWith(path.resolve(nodeDir) + path.sep)) throw new Error('path escapes node dir')
929
+ fs.mkdirSync(path.dirname(target), { recursive: true })
930
+ fs.writeFileSync(target, args.content, 'utf8')
931
+ return { node: args.node, path: args.path, bytes: Buffer.byteLength(args.content, 'utf8'), ok: true }
932
+ },
933
+ }))
934
+ ctx.tools.register(defineTool({
935
+ name: 'comfyui_reload',
936
+ description: 'Restart the local ComfyUI so custom-node source changes take effect: find the process listening on ' +
937
+ 'the ComfyUI port, kill it, then relaunch via the configured launch command. Use after comfyui_edit_source, ' +
938
+ 'then run a minimal workflow on the canvas to verify the change. Warns about running generations; cancels ' +
939
+ 'pending work by restarting.',
940
+ parameters: {},
941
+ output: { schema: { type: 'object', additionalProperties: true }, render: renderJSON },
942
+ async execute(_args) {
943
+ const value = settingsScope?.get?.()
944
+ const base = baseUrl()
945
+ const port = (() => { try { return new URL(base).port || '8188' } catch { return '8188' } })()
946
+ const cmd = (value?.launchCommand || '').trim()
947
+ const workdir = (value?.comfyuiDir || process.env.COMFYUI_DIR || '').trim() || undefined
948
+ const shell = ctx.get?.('shell')
949
+ if (!shell || typeof shell.run !== 'function') throw new Error('shell service unavailable — cannot reload ComfyUI')
950
+ if (!cmd) throw new Error('launchCommand is empty — set it in Settings → ComfyUI 画布 to allow reload')
951
+ // netstat -ano / findstr / taskkill are Windows-specific; on other
952
+ // platforms the PID hunt and kill would silently no-op and the tool
953
+ // would report a false success. Fail loud instead of pretending.
954
+ if (process.platform !== 'win32') {
955
+ throw new Error('comfyui_reload currently supports Windows only (netstat/taskkill); on macOS/Linux restart ComfyUI manually')
956
+ }
957
+ // 1. find the PID on the ComfyUI port
958
+ const netstat = await shell.run(shell.resolve({ command: `netstat -ano | findstr :${port} | findstr LISTENING`, timeoutMs: 15000 }))
959
+ const out = (netstat.stdout?.text || netstat.stdout || '').toString()
960
+ const pids = [...new Set([...out.matchAll(/\s(\d+)\s*$/gm)].map(m => m[1]))]
961
+ // 2. kill those PIDs (best-effort; ignore failures so relaunch still runs).
962
+ // taskkill is a host-level operation outside the session workspace, so
963
+ // run it unconfined like the launch below — the default workspace-write
964
+ // sandbox would deny it and the old process would survive, blocking the
965
+ // port and making the reload look like a no-op.
966
+ const killed = []
967
+ for (const pid of pids) {
968
+ try {
969
+ await shell.run(shell.resolve({
970
+ command: `taskkill /PID ${pid} /F`,
971
+ timeoutMs: 10000,
972
+ sandboxPolicy: { mode: 'danger-full-access', workspaceRoot: process.cwd() },
973
+ }))
974
+ killed.push(pid)
975
+ } catch { /* already gone or denied — proceed */ }
976
+ }
977
+ // 3. relaunch via the same unconfined path the Start button uses
978
+ shell.start(shell.resolve({
979
+ command: cmd,
980
+ ...(workdir ? { workdir } : {}),
981
+ sandboxPolicy: { mode: 'danger-full-access', workspaceRoot: process.cwd() },
982
+ }))
983
+ // 4. wait for /system_stats to come back
984
+ const deadline = Date.now() + 45000
985
+ let up = false
986
+ let lastErr = ''
987
+ while (Date.now() < deadline) {
988
+ try {
989
+ const res = await fetch(`${base}/system_stats`, { signal: AbortSignal.timeout(3000) })
990
+ if (res.ok) { up = true; break }
991
+ } catch (e) { lastErr = e instanceof Error ? e.message : String(e) }
992
+ await new Promise((done) => setTimeout(done, 1500))
993
+ }
994
+ return { reloaded: up, killed, port, launched: cmd, error: up ? null : (lastErr || 'timed out waiting for ComfyUI to come back') }
995
+ },
996
+ }))
997
+
998
+ // v0.1.2: register the skills (SOPs) that teach the agent HOW to use the
999
+ // canvas/admin tools in the right order. Runtime skills ship inside the
1000
+ // plugin, so `dsh plugin add` installs them with zero extra steps.
1001
+ const skills = ctx.skills ?? ctx.get?.('skills')
1002
+ if (skills && typeof skills.register === 'function') {
1003
+ skills.register({
1004
+ name: 'comfyui-canvas-ops',
1005
+ source: 'runtime',
1006
+ description: 'Operate the live ComfyUI canvas with the agent: read/edit/run workflows, fetch outputs.',
1007
+ whenToUse: 'Use when the user asks to make an image, edit a workflow, adjust a node, run the canvas, or fetch its outputs.',
1008
+ content: [
1009
+ '# ComfyUI 画布操作 SOP',
1010
+ '',
1011
+ '当用户要求用画布作图 / 修改工作流 / 出图时,按此顺序执行:',
1012
+ '',
1013
+ '1. comfyui_config —— 确认画布在前台(activeView=canvas)、连接正常;不在画布视图时先提示用户切到 ComfyUI 标签',
1014
+ '2. comfyui_read_workflow —— 读取当前图,向用户复述关键节点并确认要改什么',
1015
+ '3. 按需修改:comfyui_set_param(改参数)、comfyui_connect(连线)、comfyui_add_node / comfyui_remove_node(增删节点)、comfyui_inject_text(注入文本)',
1016
+ '4. 修改前说明改了哪些;运行前用 comfyui_debug 校验(不触发执行)',
1017
+ '5. comfyui_run 运行(可传 overrides 临时覆盖参数,跑完还原画布)',
1018
+ '6. comfyui_get_outputs 取回出图(支持 images/videos/gifs/audio)',
1019
+ '7. 用 vision 工具自检出图质量 → 不达标回 step 3 迭代;达标再交付',
1020
+ '',
1021
+ '安全约定:读取永远只读;写操作(改节点/运行/替换图)先向用户确认;批量用 comfyui_batch_run。',
1022
+ ].join('\n'),
1023
+ })
1024
+ skills.register({
1025
+ name: 'comfyui-admin-ops',
1026
+ source: 'runtime',
1027
+ description: 'Manage the ComfyUI environment: configure, launch, upgrade, and list/install custom nodes.',
1028
+ whenToUse: 'Use when the user asks to configure ComfyUI, start it, update it, upgrade its nodes, or manage installed custom nodes.',
1029
+ content: [
1030
+ '# ComfyUI 环境管理 SOP',
1031
+ '',
1032
+ '当用户要求配置 / 启动 / 更新 ComfyUI、管理已装节点时,按此顺序执行:',
1033
+ '',
1034
+ '1. 配置管理 —— comfyui_config 读/改连接(baseUrl / port / networkMode / 桥接 token / 启动命令 / rail 宽度)',
1035
+ '2. 启动 / 状态 —— 用设置里的启动命令拉起 ComfyUI(launchRequested 机制),轮询 /system_stats 确认在线;读 launchError 排查启动失败',
1036
+ '3. 更新 —— comfyui_upgrade:git pull 核心 + 全部节点(scope=core/nodes/all),失败不中断其余',
1037
+ '4. 节点管理 —— 用文件系统工具列出 custom_nodes/、识别 git 托管与手工目录;安装/更新节点用 shell 走 git 或 pnpm',
1038
+ '',
1039
+ '安全约定:更新 / 重启 / 装节点是重操作 —— 先确认、讲清影响(如「更新 60+ 节点」「重启中断生成」);更新前检查是否有运行中任务。',
1040
+ ].join('\n'),
1041
+ })
1042
+ skills.register({
1043
+ name: 'comfyui-video-audio-ops',
1044
+ source: 'runtime',
1045
+ description: 'Create video with audio/voiceover on ComfyUI: generate video, attach audio, fetch the merged file.',
1046
+ whenToUse: 'Use when the user wants a video with sound / voiceover / music track produced through the ComfyUI canvas.',
1047
+ content: [
1048
+ '# ComfyUI 音画创作 SOP',
1049
+ '',
1050
+ '当用户要求「视频 + 配音 / 音轨 / 音乐」时,按此顺序执行:',
1051
+ '',
1052
+ '1. 生成视频 —— 读画布上的视频工作流(如 Wan / Kling / LTX),comfyui_run 运行',
1053
+ '2. 生成音频 —— 用 comfyui_attach_file 把 TTS 生成的 wav / 音轨文件上传进 ComfyUI input/(供 LoadAudio 节点)',
1054
+ '3. 音画合成 —— 走 VideoAddAudio 或对应音画工作流,comfyui_run 合并',
1055
+ '4. comfyui_get_outputs 取回含音轨的视频文件(URL/路径)',
1056
+ '5. 交付时给出文件路径/URL;DSH 不播放,用户本地打开',
1057
+ '',
1058
+ '提示:TTS 音频只是「传输的一类文件」,上传即用;播放能力由 DSH 生态负责,本插件只做传输。',
1059
+ ].join('\n'),
1060
+ })
1061
+ skills.register({
1062
+ name: 'comfyui-dev-ops',
1063
+ source: 'runtime',
1064
+ description: 'Develop or debug a ComfyUI custom node from the conversation: read its source, edit it, reload ComfyUI, and verify on the canvas.',
1065
+ whenToUse: 'Use when the user wants to develop, modify, or debug a ComfyUI custom node (including this plugin\'s bridge node) — read/edit its source, restart ComfyUI, or verify a change on the canvas.',
1066
+ content: [
1067
+ '# ComfyUI 节点开发调试 SOP',
1068
+ '',
1069
+ '当用户要求开发 / 修改 / 调试 ComfyUI 自定义节点(含本插件的桥接节点)时,按此循环执行:',
1070
+ '',
1071
+ '1. comfyui_read_source —— 读目标节点源码(列出目录树或读单个文件,如 __init__.py / entry/bridge.js),先弄清结构与注册方式',
1072
+ '2. comfyui_edit_source —— 修改源码(受控写文件,仅限该节点目录内)',
1073
+ '3. comfyui_reload —— 重启 ComfyUI 使改动生效(会中断运行中任务,先确认);等 /system_stats 恢复在线',
1074
+ '4. 画布验证 —— 在画布上搭一个用到该节点的最小工作流,comfyui_run 运行',
1075
+ '5. comfyui_get_outputs 看结果 → 不达标回 step 2 迭代',
1076
+ '',
1077
+ '安全约定:改的是 custom_nodes/ 下的本机源码 —— 写操作先确认、讲清改哪个文件;重启前检查是否有运行中任务;改坏了节点可能让 ComfyUI 起不来(保留备份 / 可回退)。',
1078
+ ].join('\n'),
1079
+ })
1080
+ }
726
1081
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-comfyui-canvas",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Give DeepSeek Harness agents live control of your ComfyUI canvas (local or cloud): embed it as a split-screen tab, then read/edit/run workflows, debug errors, fetch output images, and batch-sweep parameters straight from the chat. Ships the ComfyUI-side bridge node.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",