dsh-fps-workbench 0.0.0-stage → 0.1.78

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/comfy-image.js ADDED
@@ -0,0 +1,557 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { createHash, randomBytes } from 'node:crypto';
3
+ import { createReadStream, createWriteStream } from 'node:fs';
4
+ import fs from 'node:fs/promises';
5
+ import net from 'node:net';
6
+ import { homedir, release, totalmem } from 'node:os';
7
+ import path from 'node:path';
8
+ import { Readable, Transform } from 'node:stream';
9
+ import { pipeline } from 'node:stream/promises';
10
+ import { setTimeout as delay } from 'node:timers/promises';
11
+
12
+ const COMFYUI_TAG = 'v0.45.0';
13
+ const COMFYUI_COMMIT = '989d93d0d968e704c589ce0146bb18c77f6b50cc';
14
+ const COMFY_CLI = 'comfy-cli==1.25.0';
15
+ const COMFY_MCP = 'comfy-mcp==0.10.0';
16
+ const MCP_SDK = 'mcp==2.3.0';
17
+ const CHECKPOINT = 'v1-5-pruned-emaonly-fp16.safetensors';
18
+ const CHECKPOINT_URL = 'https://huggingface.co/Comfy-Org/stable-diffusion-v1-5-archive/resolve/9cfd069101959ca3828bf9c04a4419870832b74f/' + CHECKPOINT;
19
+ const CHECKPOINT_SIZE = 2_132_696_762;
20
+ const CHECKPOINT_SHA256 = 'e9476a13728cd75d8279f6ec8bad753a66a1957ca375a1464dc63b37db6e3916';
21
+ const GIB = 1024 ** 3;
22
+ const RUNTIME_VERSION = 1;
23
+ const STARTUP_MS = 120_000;
24
+ const GENERATION_MS = 15 * 60_000;
25
+ const PNG_SIGNATURE = Buffer.from('89504e470d0a1a0a', 'hex');
26
+
27
+ const DIMENSIONS = {
28
+ '1:1': [512, 512],
29
+ '4:3': [576, 432],
30
+ '3:4': [432, 576],
31
+ '16:9': [640, 360],
32
+ '9:16': [360, 640],
33
+ };
34
+
35
+ // The pinned CLI has no config-directory override. Scope the CLI's config
36
+ // and workspace defaults without changing the host user's home directory.
37
+ const CLI_ISOLATION = `import os
38
+ from comfy_cli import constants
39
+ for system in constants.DEFAULT_CONFIG:
40
+ constants.DEFAULT_CONFIG[system] = os.environ["FPS_COMFY_CLI_CONFIG"]
41
+ constants.DEFAULT_COMFY_WORKSPACE[system] = os.environ["COMFY_PROJECT"]
42
+ `;
43
+
44
+ const SUBMIT_WORKFLOW = `import json, os, sys
45
+ from pathlib import Path
46
+ import anyio
47
+ from comfy_cli.cql.default_workflow import build_default_workflow
48
+ from mcp import ClientSession, StdioServerParameters, stdio_client
49
+
50
+ request = json.load(sys.stdin)
51
+
52
+ async def run():
53
+ server = StdioServerParameters(
54
+ command=sys.executable,
55
+ args=["-c", "from comfy_mcp.server import main; main()"],
56
+ env=dict(os.environ),
57
+ )
58
+ async with stdio_client(server) as streams:
59
+ async with ClientSession(*streams, read_timeout_seconds=90) as client:
60
+ await client.initialize()
61
+ if request is None:
62
+ tools = await client.list_tools()
63
+ if not any(tool.name == "run_workflow" for tool in tools.tools):
64
+ raise RuntimeError("Comfy MCP does not expose run_workflow")
65
+ print(json.dumps({"ready": True}))
66
+ return
67
+
68
+ workflow_path = Path(request["path"])
69
+ graph = build_default_workflow(
70
+ prompt=request["prompt"],
71
+ overrides=[f'{key}={value}' for key, value in request["overrides"].items()],
72
+ )
73
+ workflow_path.write_text(json.dumps(graph), encoding="utf-8")
74
+ reply = await client.call_tool("run_workflow", {
75
+ "workflow_path": str(workflow_path), "wait": False, "confirm_spend": False,
76
+ })
77
+ print(reply.model_dump_json(by_alias=True))
78
+
79
+ anyio.run(run)
80
+ `;
81
+
82
+ function unsupportedReason() {
83
+ const apple = process.platform === 'darwin' && process.arch === 'arm64' &&
84
+ Number(release().split('.')[0]) >= 23;
85
+ const windows = process.platform === 'win32' && process.arch === 'x64';
86
+ const linux = process.platform === 'linux' && process.arch === 'x64';
87
+ if (!apple && !windows && !linux) {
88
+ return 'Local image setup supports Apple Silicon with macOS 14 or later, Windows x64, and Linux x64 with glibc 2.28 or later.';
89
+ }
90
+ if (linux) {
91
+ const glibc = process.report?.getReport()?.header?.glibcVersionRuntime;
92
+ const [major, minor] = (glibc || '0.0').split('.').map(Number);
93
+ if (major < 2 || (major === 2 && minor < 28)) return 'Local image setup on Linux requires glibc 2.28 or later.';
94
+ }
95
+ if (totalmem() < 7.5 * GIB) return 'Local image generation requires at least 8 GB of system memory.';
96
+ return '';
97
+ }
98
+
99
+ function processEnvironment(extra = {}) {
100
+ const env = {};
101
+ const allowed = /^(SYSTEMROOT|WINDIR|SYSTEMDRIVE|COMSPEC|PATHEXT|TMPDIR|TMP|TEMP|LANG|LC_ALL|HTTP_PROXY|HTTPS_PROXY|ALL_PROXY|NO_PROXY|SSL_CERT_FILE|SSL_CERT_DIR|REQUESTS_CA_BUNDLE)$/i;
102
+ for (const [key, value] of Object.entries(process.env)) {
103
+ if (allowed.test(key) && value != null) env[key] = value;
104
+ }
105
+ const inheritedPath = Object.entries(process.env).find(([key]) => key.toUpperCase() === 'PATH')?.[1];
106
+ const additional = process.platform === 'win32' ? [] : [path.join(homedir(), '.local', 'bin'), '/usr/local/bin', '/usr/bin', '/bin'];
107
+ if (process.platform === 'darwin') additional.unshift('/opt/homebrew/bin');
108
+ env.PATH = [inheritedPath, ...additional].filter(Boolean).join(path.delimiter);
109
+ env.NO_PROXY = [env.NO_PROXY || env.no_proxy, '127.0.0.1', 'localhost'].filter(Boolean).join(',');
110
+ delete env.no_proxy;
111
+ return {
112
+ ...env,
113
+ PIP_CONFIG_FILE: process.platform === 'win32' ? 'nul' : '/dev/null',
114
+ PIP_NO_INPUT: '1',
115
+ PYTHONNOUSERSITE: '1',
116
+ PYTHONUTF8: '1',
117
+ GIT_TERMINAL_PROMPT: '0',
118
+ GIT_CONFIG_NOSYSTEM: '1',
119
+ GIT_CONFIG_GLOBAL: process.platform === 'win32' ? 'NUL' : '/dev/null',
120
+ DO_NOT_TRACK: '1',
121
+ COMFY_NO_TELEMETRY: '1',
122
+ ...extra,
123
+ };
124
+ }
125
+
126
+ function startProcess(file, args, { cwd, env, input } = {}) {
127
+ const child = spawn(file, args, {
128
+ cwd, env, detached: process.platform !== 'win32', windowsHide: true,
129
+ stdio: [input === undefined ? 'ignore' : 'pipe', 'pipe', 'pipe'],
130
+ });
131
+ let output = '';
132
+ let diagnostics = '';
133
+ let failure;
134
+ child.stdout.setEncoding('utf8').on('data', (data) => { output = (output + data).slice(-32_768); });
135
+ child.stderr.setEncoding('utf8').on('data', (data) => { diagnostics = (diagnostics + data).slice(-8_192); });
136
+ if (child.stdin) {
137
+ child.stdin.on('error', () => {});
138
+ child.stdin.end(input);
139
+ }
140
+ const result = new Promise((resolve) => {
141
+ child.once('error', (error) => { failure = error; });
142
+ child.once('close', (code, signal) => resolve({ code, signal, failure, output, diagnostics }));
143
+ });
144
+ async function stop() {
145
+ if (child.pid && child.exitCode == null && child.signalCode == null) {
146
+ if (process.platform === 'win32') {
147
+ const killer = spawn('taskkill.exe', ['/pid', String(child.pid), '/t', '/f'], {
148
+ windowsHide: true, stdio: 'ignore',
149
+ });
150
+ killer.once('error', () => child.kill());
151
+ killer.once('exit', (code) => { if (code) child.kill(); });
152
+ } else {
153
+ try { process.kill(-child.pid, 'SIGKILL'); } catch {}
154
+ }
155
+ }
156
+ await result;
157
+ }
158
+ return {
159
+ result, stop,
160
+ alive: () => child.pid && child.exitCode == null && child.signalCode == null && !failure,
161
+ };
162
+ }
163
+
164
+ function cleanMessage(value) {
165
+ return String(value ?? '')
166
+ .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '')
167
+ .replace(/https?:\/\/\S+/g, '[URL]')
168
+ .replace(/\s+/g, ' ')
169
+ .trim()
170
+ .slice(-400);
171
+ }
172
+
173
+ async function runCommand(file, args, { signal, input, cwd, env } = {}) {
174
+ signal?.throwIfAborted();
175
+ const process = startProcess(file, args, { input, cwd, env });
176
+ const stop = () => { void process.stop(); };
177
+ signal?.addEventListener('abort', stop, { once: true });
178
+ if (signal?.aborted) stop();
179
+ try {
180
+ const result = await process.result;
181
+ signal?.throwIfAborted();
182
+ if (result.failure) throw result.failure;
183
+ if (result.code !== 0) {
184
+ throw new Error(cleanMessage(result.diagnostics || result.output) || `Process exited with ${result.code ?? result.signal}.`);
185
+ }
186
+ return result.output.trim();
187
+ } finally {
188
+ signal?.removeEventListener('abort', stop);
189
+ }
190
+ }
191
+
192
+ async function findPython(options) {
193
+ const windows = process.platform === 'win32';
194
+ const versions = ['3.12', '3.13', '3.11'];
195
+ const candidates = windows
196
+ ? [...versions.map((version) => ['py.exe', ['-' + version]]), ['python.exe', []]]
197
+ : [...versions.map((version) => ['python' + version, []]), ['python3', []]];
198
+ const probe = 'import ensurepip, json, platform, sys, venv; print(json.dumps([sys.executable, *sys.version_info[:2], platform.machine().lower()]))';
199
+ for (const [command, args] of candidates) {
200
+ try {
201
+ const output = await runCommand(command, [...args, '-I', '-c', probe], {
202
+ ...options, signal: AbortSignal.any([options.signal, AbortSignal.timeout(6_000)]),
203
+ });
204
+ const [exe, major, minor, arch] = JSON.parse(output);
205
+ const supportedArchitecture = process.arch === 'arm64' ? ['arm64', 'aarch64'] : ['x86_64', 'amd64'];
206
+ if (major === 3 && [11, 12, 13].includes(minor) && supportedArchitecture.includes(arch) && path.isAbsolute(exe)) {
207
+ return exe;
208
+ }
209
+ } catch {
210
+ options.signal.throwIfAborted();
211
+ }
212
+ }
213
+ throw new Error('Install native Python 3.11, 3.12 or 3.13 with venv and pip, then retry setup.');
214
+ }
215
+
216
+ async function pytorchIndex(options) {
217
+ if (process.platform === 'darwin') return [];
218
+ try {
219
+ const output = await runCommand('nvidia-smi', ['--query-gpu=driver_version,compute_cap', '--format=csv,noheader'], {
220
+ ...options, signal: AbortSignal.any([options.signal, AbortSignal.timeout(8_000)]),
221
+ });
222
+ const minimumDriver = process.platform === 'win32' ? 571 : 570;
223
+ if (output.split(/\r?\n/).some((row) => {
224
+ const [driver, capability] = row.split(',').map((part) => parseFloat(part.trim()));
225
+ return driver >= minimumDriver && capability >= 7.5;
226
+ })) return ['--index-url', 'https://download.pytorch.org/whl/cu128'];
227
+ } catch {
228
+ options.signal.throwIfAborted();
229
+ }
230
+ return ['--index-url', 'https://download.pytorch.org/whl/cpu'];
231
+ }
232
+
233
+ async function checkpointMatches(file, signal) {
234
+ const stat = await fs.lstat(file).catch(() => null);
235
+ if (!stat?.isFile() || stat.size !== CHECKPOINT_SIZE) return false;
236
+ const hash = createHash('sha256');
237
+ for await (const chunk of createReadStream(file, { signal })) hash.update(chunk);
238
+ return hash.digest('hex') === CHECKPOINT_SHA256;
239
+ }
240
+
241
+ async function installCheckpoint(file, signal) {
242
+ if (await checkpointMatches(file, signal)) return;
243
+ await fs.mkdir(path.dirname(file), { recursive: true });
244
+ const partial = file + '.download';
245
+ await fs.rm(partial, { force: true });
246
+ try {
247
+ const response = await fetch(CHECKPOINT_URL, { signal });
248
+ if (!response.ok || !response.body) throw new Error(`Model download returned HTTP ${response.status}.`);
249
+ const hash = createHash('sha256');
250
+ let size = 0;
251
+ const verify = new Transform({
252
+ transform(chunk, _encoding, next) {
253
+ size += chunk.length;
254
+ if (size > CHECKPOINT_SIZE) return next(new Error('Model download exceeded the expected size.'));
255
+ hash.update(chunk);
256
+ next(null, chunk);
257
+ },
258
+ });
259
+ await pipeline(Readable.fromWeb(response.body), verify, createWriteStream(partial, { flags: 'wx', mode: 0o600 }), { signal });
260
+ if (size !== CHECKPOINT_SIZE || hash.digest('hex') !== CHECKPOINT_SHA256) {
261
+ throw new Error('The downloaded model failed its integrity check.');
262
+ }
263
+ signal.throwIfAborted();
264
+ await fs.rm(file, { force: true });
265
+ await fs.rename(partial, file);
266
+ } finally {
267
+ await fs.rm(partial, { force: true });
268
+ }
269
+ }
270
+
271
+ async function availablePort() {
272
+ const server = net.createServer();
273
+ return new Promise((resolve, reject) => {
274
+ server.once('error', reject);
275
+ server.listen(0, '127.0.0.1', () => {
276
+ const { port } = server.address();
277
+ server.close((error) => error ? reject(error) : resolve(port));
278
+ });
279
+ });
280
+ }
281
+
282
+ async function localJson(base, endpoint, signal) {
283
+ const response = await fetch(base + endpoint, {
284
+ redirect: 'error', signal: AbortSignal.any([signal, AbortSignal.timeout(4_000)]),
285
+ });
286
+ if (!response.ok) throw new Error(`ComfyUI returned HTTP ${response.status}.`);
287
+ return response.json();
288
+ }
289
+
290
+ async function waitForComfy(server, address, signal) {
291
+ const until = Date.now() + STARTUP_MS;
292
+ while (Date.now() < until) {
293
+ signal.throwIfAborted();
294
+ if (!server.alive()) {
295
+ const result = await server.result;
296
+ throw new Error(cleanMessage(result.diagnostics || result.failure?.message) || 'ComfyUI stopped while starting.');
297
+ }
298
+ try {
299
+ const data = await localJson(address, '/system_stats', signal);
300
+ if (data?.system?.comfyui_version === COMFYUI_TAG.slice(1) && server.alive()) return;
301
+ } catch {}
302
+ await delay(600, undefined, { signal });
303
+ }
304
+ throw new Error('ComfyUI did not start in time.');
305
+ }
306
+
307
+ function parseMcpSubmission(output) {
308
+ const result = JSON.parse(output.split(/\r?\n/).at(-1));
309
+ const rawText = result.content?.find((item) => item.type === 'text')?.text;
310
+ if (result.isError) throw new Error(cleanMessage(rawText) || 'Comfy MCP rejected the workflow.');
311
+ const payload = result.structuredContent ?? JSON.parse(rawText || '{}');
312
+ const promptId = payload.prompt_id ?? payload.result?.prompt_id;
313
+ if (typeof promptId !== 'string' || !/^[a-f\d-]{36}$/i.test(promptId)) {
314
+ throw new Error('Comfy MCP did not return a job ID.');
315
+ }
316
+ return promptId;
317
+ }
318
+
319
+ async function waitForPng(server, address, promptId, outputDirectory, signal) {
320
+ while (server.alive()) {
321
+ signal.throwIfAborted();
322
+ const history = await localJson(address, '/history/' + promptId, signal).catch((error) => {
323
+ signal.throwIfAborted();
324
+ if (error.name !== 'TimeoutError' && !(error instanceof TypeError)) throw error;
325
+ return {};
326
+ });
327
+ const job = history[promptId];
328
+ if (!job) {
329
+ await delay(1_000, undefined, { signal });
330
+ continue;
331
+ }
332
+ const problem = job.status?.messages?.find(([kind]) => kind === 'execution_error')?.[1];
333
+ if (!job.status?.completed || problem) {
334
+ throw new Error(cleanMessage(problem?.exception_message) || 'ComfyUI could not complete the image.');
335
+ }
336
+ const output = Object.values(job.outputs || {}).flatMap((entry) => entry.images || [])
337
+ .find((entry) => entry.type === 'output' && !entry.subfolder && /^[\w.-]+\.png$/i.test(entry.filename));
338
+ if (!output) throw new Error('ComfyUI returned no PNG image.');
339
+ const file = path.join(outputDirectory, output.filename);
340
+ const stat = await fs.lstat(file);
341
+ if (!stat.isFile() || stat.size > 20 * 1024 * 1024) throw new Error('The generated image is invalid or too large.');
342
+ const bytes = await fs.readFile(file, { signal });
343
+ if (!bytes.subarray(0, PNG_SIGNATURE.length).equals(PNG_SIGNATURE)) throw new Error('ComfyUI returned an invalid PNG.');
344
+ return { bytes, extension: 'png', mediaType: 'image/png' };
345
+ }
346
+ const result = await server.result;
347
+ throw new Error(cleanMessage(result.diagnostics || result.failure?.message) || 'ComfyUI stopped before completing the image.');
348
+ }
349
+
350
+ export function createComfyImageRuntime({ directory }) {
351
+ if (!path.isAbsolute(directory)) throw new TypeError('ComfyUI requires an absolute runtime directory.');
352
+ const workspace = path.join(directory, 'ComfyUI');
353
+ const venv = path.join(directory, 'python');
354
+ const bin = path.join(venv, process.platform === 'win32' ? 'Scripts' : 'bin');
355
+ const python = path.join(bin, process.platform === 'win32' ? 'python.exe' : 'python');
356
+ const comfy = path.join(bin, process.platform === 'win32' ? 'comfy.exe' : 'comfy');
357
+ const isolation = path.join(directory, 'isolation');
358
+ const shim = path.join(isolation, 'sitecustomize.py');
359
+ const config = path.join(directory, 'comfy-cli');
360
+ const cache = path.join(directory, 'cache');
361
+ const marker = path.join(directory, 'ready.json');
362
+ const checkpoint = path.join(workspace, 'models', 'checkpoints', CHECKPOINT);
363
+ const main = path.join(workspace, 'main.py');
364
+ const unsupported = unsupportedReason();
365
+ const lifecycle = new AbortController();
366
+ let transient;
367
+ let installation;
368
+ let installControl;
369
+ let generating = false;
370
+
371
+ function runtimeEnv(address) {
372
+ return processEnvironment({
373
+ PYTHONPATH: isolation,
374
+ FPS_COMFY_CLI_CONFIG: config,
375
+ COMFY_PROJECT: workspace,
376
+ COMFY_BIN: comfy,
377
+ COMFY_LOCAL_URL: address,
378
+ COMFY_CACHE_DIR: cache,
379
+ XDG_CACHE_HOME: cache,
380
+ HF_HOME: cache,
381
+ HF_HUB_OFFLINE: '1',
382
+ PYTORCH_ENABLE_MPS_FALLBACK: '1',
383
+ });
384
+ }
385
+
386
+ async function ready() {
387
+ const installed = await fs.readFile(marker, 'utf8').then(JSON.parse).catch(() => null);
388
+ if (installed?.version !== RUNTIME_VERSION || !['cpu', 'mps', 'cuda'].includes(installed.device)) return null;
389
+ const expected = await fs.lstat(checkpoint).catch(() => null);
390
+ if (!expected?.isFile() || expected.size !== CHECKPOINT_SIZE) return null;
391
+ const found = await Promise.all([main, python, comfy, shim].map((file) => fs.access(file).then(() => true, () => false)));
392
+ return found.every(Boolean) ? installed : null;
393
+ }
394
+
395
+ async function status() {
396
+ if (unsupported) return { state: 'unsupported', detail: unsupported };
397
+ if (transient) return { ...transient };
398
+ const installed = await ready();
399
+ return installed ? { state: 'ready', device: installed.device } : { state: 'missing' };
400
+ }
401
+
402
+ async function withComfy(device, signal, operation) {
403
+ const job = await fs.mkdtemp(path.join(directory, 'job-'));
404
+ let server;
405
+ try {
406
+ signal.throwIfAborted();
407
+ const port = await availablePort();
408
+ const address = `http://127.0.0.1:${port}`;
409
+ const output = path.join(job, 'output');
410
+ const input = path.join(job, 'input');
411
+ const temp = path.join(job, 'temp');
412
+ const user = path.join(job, 'user');
413
+ await Promise.all([output, input, temp, user].map((dir) => fs.mkdir(dir, { mode: 0o700 })));
414
+ const env = runtimeEnv(address);
415
+ const args = [main, '--listen', '127.0.0.1', '--port', String(port), '--offline',
416
+ '--disable-all-custom-nodes', '--disable-metadata', '--output-directory', output,
417
+ '--temp-directory', temp, '--input-directory', input, '--user-directory', user];
418
+ if (device === 'cpu') args.push('--cpu');
419
+ signal.throwIfAborted();
420
+ server = startProcess(python, args, { cwd: workspace, env });
421
+ await waitForComfy(server, address, signal);
422
+ return await operation({ server, address, env, job, output });
423
+ } finally {
424
+ await server?.stop();
425
+ await fs.rm(job, { recursive: true, force: true }).catch(() => {});
426
+ }
427
+ }
428
+
429
+ async function setUp(signal) {
430
+ let scratch;
431
+ const stage = (value) => { transient = { state: 'installing', stage: value }; };
432
+ try {
433
+ if (await ready()) { transient = undefined; return; }
434
+ await fs.mkdir(directory, { recursive: true, mode: 0o700 });
435
+ const space = await fs.statfs(directory).catch(() => null);
436
+ if (space && space.bavail * space.bsize < 16 * GIB) {
437
+ throw new Error('Local setup requires 16 GiB of free disk space.');
438
+ }
439
+ scratch = await fs.mkdtemp(path.join(directory, 'setup-'));
440
+ const options = { signal, env: processEnvironment({ TMPDIR: scratch, TMP: scratch, TEMP: scratch }) };
441
+ const base = await findPython(options);
442
+ try {
443
+ await runCommand('git', ['--version'], { ...options, signal: AbortSignal.any([signal, AbortSignal.timeout(10_000)]) });
444
+ } catch {
445
+ signal.throwIfAborted();
446
+ throw new Error('Install Git and make it available on PATH, then retry setup.');
447
+ }
448
+ const index = await pytorchIndex(options);
449
+
450
+ stage('environment');
451
+ await runCommand(base, ['-I', '-m', 'venv', venv], options);
452
+
453
+ stage('comfyui');
454
+ const revision = await runCommand('git', ['-C', workspace, 'rev-parse', 'HEAD'], options).catch(() => '');
455
+ signal.throwIfAborted();
456
+ if (revision !== COMFYUI_COMMIT) {
457
+ await fs.rm(workspace, { recursive: true, force: true });
458
+ await runCommand('git', ['clone', '--depth', '1', '--single-branch', '--branch', COMFYUI_TAG,
459
+ 'https://github.com/Comfy-Org/ComfyUI.git', workspace], options);
460
+ const checkedOut = await runCommand('git', ['-C', workspace, 'rev-parse', 'HEAD'], options);
461
+ if (checkedOut !== COMFYUI_COMMIT) throw new Error('The official ComfyUI version did not match the pinned revision.');
462
+ }
463
+
464
+ stage('dependencies');
465
+ const pip = ['-I', '-m', 'pip', 'install', '--disable-pip-version-check', '--no-cache-dir', '--only-binary=:all:'];
466
+ const torchPackages = ['torch==2.11.0', 'torchvision==0.26.0'];
467
+ await runCommand(python, [...pip, ...index, ...torchPackages], options);
468
+ await runCommand(python, [...pip, '--index-url', 'https://pypi.org/simple',
469
+ '-r', path.join(workspace, 'requirements.txt'), ...torchPackages, COMFY_CLI, COMFY_MCP, MCP_SDK], options);
470
+ const device = await runCommand(python, ['-I', '-c', 'import torch; print("cuda" if torch.cuda.is_available() else "mps" if torch.backends.mps.is_available() else "cpu")'], options);
471
+
472
+ stage('verifying');
473
+ await Promise.all([config, isolation, cache].map((dir) => fs.mkdir(dir, { recursive: true, mode: 0o700 })));
474
+ await fs.writeFile(shim, CLI_ISOLATION, { mode: 0o600 });
475
+ await withComfy(device, signal, ({ env }) =>
476
+ runCommand(python, ['-c', SUBMIT_WORKFLOW], { signal, cwd: workspace, env, input: 'null' }));
477
+
478
+ stage('model');
479
+ await installCheckpoint(checkpoint, signal);
480
+
481
+ const partial = marker + '.' + randomBytes(8).toString('hex');
482
+ try {
483
+ await fs.writeFile(partial, JSON.stringify({ version: RUNTIME_VERSION, device }), { mode: 0o600, signal });
484
+ signal.throwIfAborted();
485
+ await fs.rename(partial, marker);
486
+ } finally {
487
+ await fs.rm(partial, { force: true }).catch(() => {});
488
+ }
489
+ transient = undefined;
490
+ } catch (error) {
491
+ if (signal.aborted && signal.reason?.name !== 'TimeoutError') {
492
+ transient = undefined;
493
+ return;
494
+ }
495
+ transient = {
496
+ state: 'error', stage: transient?.stage,
497
+ detail: signal.aborted ? 'ComfyUI setup timed out. Retry the download and setup.' : cleanMessage(error?.message),
498
+ };
499
+ throw new Error(transient.detail);
500
+ } finally {
501
+ if (scratch) await fs.rm(scratch, { recursive: true, force: true }).catch(() => {});
502
+ }
503
+ }
504
+
505
+ function install() {
506
+ if (unsupported) return Promise.reject(new Error(unsupported));
507
+ if (lifecycle.signal.aborted) return Promise.reject(lifecycle.signal.reason);
508
+ if (installation) return installation;
509
+ if (generating) return Promise.reject(new Error('ComfyUI is currently generating an image.'));
510
+ installControl = new AbortController();
511
+ const signal = AbortSignal.any([lifecycle.signal, installControl.signal, AbortSignal.timeout(90 * 60_000)]);
512
+ transient = { state: 'installing', stage: 'checking' };
513
+ installation = setUp(signal).finally(() => {
514
+ installation = undefined;
515
+ installControl = undefined;
516
+ });
517
+ return installation;
518
+ }
519
+
520
+ async function cancelInstall() {
521
+ installControl?.abort();
522
+ await installation?.catch(() => {});
523
+ return status();
524
+ }
525
+
526
+ async function generate({ prompt, aspectRatio = '1:1', signal }) {
527
+ if (unsupported) throw new Error(unsupported);
528
+ if (installation) throw new Error('Wait until ComfyUI setup is complete.');
529
+ if (generating) throw new Error('ComfyUI is already generating an image.');
530
+ if (!Object.hasOwn(DIMENSIONS, aspectRatio)) throw new Error('This aspect ratio is unsupported by the local image workflow.');
531
+ const [width, height] = DIMENSIONS[aspectRatio];
532
+ const runSignal = AbortSignal.any([lifecycle.signal, AbortSignal.timeout(GENERATION_MS), ...(signal ? [signal] : [])]);
533
+ generating = true;
534
+ try {
535
+ const installed = await ready();
536
+ if (!installed) throw new Error('Complete ComfyUI setup in image generation settings.');
537
+ return await withComfy(installed.device, runSignal, async ({ server, address, env, job, output }) => {
538
+ const input = JSON.stringify({
539
+ path: path.join(job, 'workflow.json'), prompt,
540
+ overrides: { checkpoint: CHECKPOINT, width, height, seed: randomBytes(4).readUInt32BE() },
541
+ });
542
+ const reply = await runCommand(python, ['-c', SUBMIT_WORKFLOW], {
543
+ cwd: workspace, env, input, signal: runSignal,
544
+ });
545
+ return waitForPng(server, address, parseMcpSubmission(reply), output, runSignal);
546
+ });
547
+ } finally {
548
+ generating = false;
549
+ }
550
+ }
551
+
552
+ function dispose() {
553
+ lifecycle.abort();
554
+ }
555
+
556
+ return { status, install, cancelInstall, generate, dispose };
557
+ }
@@ -0,0 +1,3 @@
1
+ - insert:
2
+ - id: claude-code-workbench
3
+ name: dsh-fps-workbench
package/icon.svg ADDED
@@ -0,0 +1,6 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="8.5 8.5 47 47">
2
+ <path d="M31.1 6Q32 4.3 32.9 6L39.1 18.1Q40.5 21 37.7 23L32 26.3 26.3 23Q23.5 21 24.9 18.1Z" fill="#f5b59a" transform="rotate(135 32 32)"/>
3
+ <path d="M31.1 6Q32 4.3 32.9 6L39.1 18.1Q40.5 21 37.7 23L32 26.3 26.3 23Q23.5 21 24.9 18.1Z" fill="#e98e70" transform="rotate(225 32 32)"/>
4
+ <path d="M31.1 6Q32 4.3 32.9 6L39.1 18.1Q40.5 21 37.7 23L32 26.3 26.3 23Q23.5 21 24.9 18.1Z" fill="#f5b59a" transform="rotate(315 32 32)"/>
5
+ <path d="M31.1 6Q32 4.3 32.9 6L39.1 18.1Q40.5 21 37.7 23L32 26.3 26.3 23Q23.5 21 24.9 18.1Z" fill="#e98e70" transform="rotate(45 32 32)"/>
6
+ </svg>