dsh-artcraft-plugin-soundcraft 0.2.0 → 0.2.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/package.json +1 -1
- package/src/kit/exec.js +65 -0
- package/src/kit/mcp.js +87 -9
- package/src/kit/profiles.mjs +12 -0
- package/src/kit/tools.js +209 -39
- package/src/kit/types.js +4 -0
package/package.json
CHANGED
package/src/kit/exec.js
CHANGED
|
@@ -155,6 +155,71 @@ export async function runCraft(options )
|
|
|
155
155
|
})
|
|
156
156
|
}
|
|
157
157
|
|
|
158
|
+
/**
|
|
159
|
+
* Run one craft process in the background and stream its output to a log file.
|
|
160
|
+
*
|
|
161
|
+
* Long operations — a 4K render, a texture bake, an audio bounce — outlast any
|
|
162
|
+
* reasonable tool timeout, and killing them at the timer loses the work. A job
|
|
163
|
+
* keeps the process alive after the tool returns, so the caller can poll or wait
|
|
164
|
+
* and still read the output afterwards.
|
|
165
|
+
*/
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
/** A started job, including the process handle the runtime uses to observe it. */
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
/** Live job state kept by the runtime. */
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Start a craft CLI in the background.
|
|
199
|
+
* @param options - profile, binary, argv, cwd and the log file to write.
|
|
200
|
+
* @returns a handle the caller can poll through its runtime.
|
|
201
|
+
*/
|
|
202
|
+
export function startCraftJob(options ) {
|
|
203
|
+
const { profile, binary, argv, cwd, logPath, id } = options
|
|
204
|
+
const child = spawn(binary.path, [...argv], {
|
|
205
|
+
cwd,
|
|
206
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
207
|
+
env: { ...process.env, NO_COLOR: '1', TERM: 'dumb' },
|
|
208
|
+
windowsHide: true,
|
|
209
|
+
})
|
|
210
|
+
const write = (chunk ) => {
|
|
211
|
+
try {
|
|
212
|
+
appendFileSync(logPath, chunk)
|
|
213
|
+
} catch {
|
|
214
|
+
// A full disk must not take the tool down; the process keeps running.
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
child.stdout?.on('data', write)
|
|
218
|
+
child.stderr?.on('data', write)
|
|
219
|
+
void profile
|
|
220
|
+
return { id, pid: child.pid, logPath, startedAt: Date.now(), argv, child }
|
|
221
|
+
}
|
|
222
|
+
|
|
158
223
|
/**
|
|
159
224
|
* Compose the argv for one headless `run` invocation.
|
|
160
225
|
*
|
package/src/kit/mcp.js
CHANGED
|
@@ -14,6 +14,8 @@
|
|
|
14
14
|
|
|
15
15
|
import { spawn } from 'node:child_process'
|
|
16
16
|
|
|
17
|
+
import { mkdirSync, writeFileSync } from 'node:fs'
|
|
18
|
+
import { join } from 'node:path'
|
|
17
19
|
import { killTree } from './exec.js'
|
|
18
20
|
|
|
19
21
|
|
|
@@ -31,6 +33,33 @@ import { killTree } from './exec.js'
|
|
|
31
33
|
|
|
32
34
|
|
|
33
35
|
|
|
36
|
+
/** Extensions for the image types an MCP tool can return. */
|
|
37
|
+
const IMAGE_EXTENSIONS = {
|
|
38
|
+
'image/png': 'png', 'image/jpeg': 'jpg', 'image/webp': 'webp', 'image/gif': 'gif', 'image/svg+xml': 'svg',
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Write one base64 image from a tool result to disk.
|
|
43
|
+
* @param data - base64 payload.
|
|
44
|
+
* @param mimeType - media type the server declared.
|
|
45
|
+
* @param dir - directory to write into.
|
|
46
|
+
* @param craft - craft id, used in the file name.
|
|
47
|
+
* @param tool - tool name, used in the file name.
|
|
48
|
+
* @returns the absolute path, or `undefined` when the payload cannot be written.
|
|
49
|
+
*/
|
|
50
|
+
function saveImage(data , mimeType , dir , craft , tool ) {
|
|
51
|
+
try {
|
|
52
|
+
const extension = IMAGE_EXTENSIONS[mimeType ?? ''] ?? 'png'
|
|
53
|
+
const safeTool = tool.replace(/[^\w.-]+/g, '-')
|
|
54
|
+
mkdirSync(dir, { recursive: true })
|
|
55
|
+
const file = join(dir, `${craft}-${safeTool}-${Date.now()}.${extension}`)
|
|
56
|
+
writeFileSync(file, Buffer.from(data, 'base64'))
|
|
57
|
+
return file
|
|
58
|
+
} catch {
|
|
59
|
+
return undefined
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
34
63
|
/** Default MCP protocol revision; servers negotiate down if they need to. */
|
|
35
64
|
const PROTOCOL_VERSION = '2024-11-05'
|
|
36
65
|
|
|
@@ -52,6 +81,8 @@ export class CraftMcpClient {
|
|
|
52
81
|
#idleTimer
|
|
53
82
|
#handshake
|
|
54
83
|
#tools
|
|
84
|
+
/** Last stderr lines, so a failed attach can say why the app refused. */
|
|
85
|
+
#stderrTail = []
|
|
55
86
|
#closed = false
|
|
56
87
|
|
|
57
88
|
constructor(profile , binary , args = []) {
|
|
@@ -91,17 +122,26 @@ export class CraftMcpClient {
|
|
|
91
122
|
child.stdout.on('data', (chunk ) => { this.#onData(chunk) })
|
|
92
123
|
child.stderr.setEncoding('utf8')
|
|
93
124
|
// Craft servers announce themselves on stderr; it is not an error stream.
|
|
125
|
+
// The tail is kept so a refused attach can repeat the app's own reason
|
|
126
|
+
// ("no control token", "cannot connect to 127.0.0.1:7979") instead of a
|
|
127
|
+
// bare "server exited".
|
|
94
128
|
child.stderr.on('data', (chunk ) => {
|
|
95
129
|
const text = chunk.trim()
|
|
96
|
-
if (text.length
|
|
130
|
+
if (text.length === 0) return
|
|
131
|
+
for (const line of text.split('\n')) {
|
|
132
|
+
this.#stderrTail.push(line.trim())
|
|
133
|
+
if (this.#stderrTail.length > 8) this.#stderrTail.shift()
|
|
134
|
+
}
|
|
135
|
+
if (process.env.DSH_ARTCRAFT_TRACE !== undefined) {
|
|
97
136
|
process.stderr.write(`[${this.#profile.id} mcp] ${text}\n`)
|
|
98
137
|
}
|
|
99
138
|
})
|
|
100
139
|
child.on('exit', () => {
|
|
101
140
|
this.#child = undefined
|
|
141
|
+
const reason = this.#stderrTail.filter(line => line.length > 0).slice(-2).join(' ')
|
|
102
142
|
for (const [, pending] of this.#pending) {
|
|
103
143
|
clearTimeout(pending.timer)
|
|
104
|
-
pending.reject(new Error(`${this.#profile.product}: MCP server exited`))
|
|
144
|
+
pending.reject(new Error(`${this.#profile.product}: MCP server exited${reason.length > 0 ? ` (${reason})` : ''}`))
|
|
105
145
|
}
|
|
106
146
|
this.#pending.clear()
|
|
107
147
|
})
|
|
@@ -134,22 +174,60 @@ export class CraftMcpClient {
|
|
|
134
174
|
|
|
135
175
|
/**
|
|
136
176
|
* Call one tool.
|
|
177
|
+
*
|
|
178
|
+
* Image results — a render, a screenshot, a page thumbnail — are written to
|
|
179
|
+
* `artifactsDir` and referenced by path instead of being dropped: the caller
|
|
180
|
+
* can then look at them with `read_image`, which is the only way an agent
|
|
181
|
+
* verifies what it drew.
|
|
137
182
|
* @param name - tool name as declared by the server.
|
|
138
183
|
* @param args - the tool's JSON arguments.
|
|
139
|
-
* @
|
|
184
|
+
* @param artifactsDir - directory for returned images, when one is configured.
|
|
185
|
+
* @returns the text content, with any images saved and named by path.
|
|
140
186
|
*/
|
|
141
|
-
async callTool(name , args ) {
|
|
187
|
+
async callTool(name , args , artifactsDir ) {
|
|
142
188
|
const result = await this.#request('tools/call', { name, arguments: args })
|
|
143
|
-
|
|
189
|
+
|
|
144
190
|
|
|
145
191
|
|
|
146
|
-
const
|
|
147
|
-
|
|
148
|
-
.
|
|
149
|
-
|
|
192
|
+
const parts = []
|
|
193
|
+
for (const block of result.content ?? []) {
|
|
194
|
+
if (block.type === 'text') {
|
|
195
|
+
parts.push(block.text ?? '')
|
|
196
|
+
continue
|
|
197
|
+
}
|
|
198
|
+
if (block.type === 'image' && typeof block.data === 'string' && artifactsDir !== undefined) {
|
|
199
|
+
const saved = saveImage(block.data, block.mimeType, artifactsDir, this.#profile.id, name)
|
|
200
|
+
parts.push(saved === undefined
|
|
201
|
+
? '[image content]'
|
|
202
|
+
: `[image] ${saved}\nUse read_image on that path to look at it.`)
|
|
203
|
+
continue
|
|
204
|
+
}
|
|
205
|
+
parts.push(`[${block.type ?? 'unknown'} content]`)
|
|
206
|
+
}
|
|
207
|
+
const text = parts.join('\n').trim()
|
|
150
208
|
return `${text.length > 0 ? text : '(no content)'}${result.isError === true ? '\n[mcp: tool reported an error]' : ''}`
|
|
151
209
|
}
|
|
152
210
|
|
|
211
|
+
/** List the resources the server advertises (documents, command catalogues). */
|
|
212
|
+
async listResources() {
|
|
213
|
+
try {
|
|
214
|
+
const result = await this.#request('resources/list', {})
|
|
215
|
+
return Array.isArray(result.resources) ? result.resources : []
|
|
216
|
+
} catch {
|
|
217
|
+
return []
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** List the prompts the server advertises (curated multi-step workflows). */
|
|
222
|
+
async listPrompts() {
|
|
223
|
+
try {
|
|
224
|
+
const result = await this.#request('prompts/list', {})
|
|
225
|
+
return Array.isArray(result.prompts) ? result.prompts : []
|
|
226
|
+
} catch {
|
|
227
|
+
return []
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
153
231
|
/** Terminate the server process now. */
|
|
154
232
|
close() {
|
|
155
233
|
this.#closed = true
|
package/src/kit/profiles.mjs
CHANGED
|
@@ -54,6 +54,7 @@ export const CRAFT_PROFILES = [
|
|
|
54
54
|
catalogJson: true,
|
|
55
55
|
catalogArgs: ['commands'],
|
|
56
56
|
mcpArgs: ['mcp'],
|
|
57
|
+
mcpAttach: { flag: '--connect', format: 'hostPort' },
|
|
57
58
|
},
|
|
58
59
|
{
|
|
59
60
|
id: 'gridcraft',
|
|
@@ -80,6 +81,7 @@ export const CRAFT_PROFILES = [
|
|
|
80
81
|
catalogJson: true,
|
|
81
82
|
catalogArgs: ['commands', '--json'],
|
|
82
83
|
mcpArgs: ['mcp'],
|
|
84
|
+
mcpAttach: { flag: '--connect', format: 'port' },
|
|
83
85
|
},
|
|
84
86
|
{
|
|
85
87
|
id: 'soundcraft',
|
|
@@ -107,6 +109,7 @@ export const CRAFT_PROFILES = [
|
|
|
107
109
|
describeArgs: ['describe'],
|
|
108
110
|
catalogArgs: ['commands'],
|
|
109
111
|
mcpArgs: ['mcp'],
|
|
112
|
+
mcpAttach: { flag: '--connect', format: 'port' },
|
|
110
113
|
},
|
|
111
114
|
{
|
|
112
115
|
id: 'deckcraft',
|
|
@@ -133,6 +136,7 @@ export const CRAFT_PROFILES = [
|
|
|
133
136
|
catalogJson: true,
|
|
134
137
|
catalogArgs: ['commands'],
|
|
135
138
|
mcpArgs: ['mcp'],
|
|
139
|
+
mcpAttach: { flag: '--connect', format: 'port' },
|
|
136
140
|
},
|
|
137
141
|
{
|
|
138
142
|
id: 'wordcraft',
|
|
@@ -158,6 +162,7 @@ export const CRAFT_PROFILES = [
|
|
|
158
162
|
catalogJson: true,
|
|
159
163
|
catalogArgs: ['commands', '--json'],
|
|
160
164
|
mcpArgs: ['mcp'],
|
|
165
|
+
mcpAttach: { flag: '--connect', format: 'hostPort' },
|
|
161
166
|
},
|
|
162
167
|
{
|
|
163
168
|
id: 'photocraft',
|
|
@@ -186,6 +191,7 @@ export const CRAFT_PROFILES = [
|
|
|
186
191
|
catalogJson: true,
|
|
187
192
|
catalogArgs: ['commands', '--json'],
|
|
188
193
|
mcpArgs: ['mcp'],
|
|
194
|
+
mcpAttach: { flag: '--bridge', format: 'hostPort' },
|
|
189
195
|
},
|
|
190
196
|
{
|
|
191
197
|
id: 'lightcraft',
|
|
@@ -272,6 +278,7 @@ export const CRAFT_PROFILES = [
|
|
|
272
278
|
catalogJson: true,
|
|
273
279
|
catalogArgs: ['commands'],
|
|
274
280
|
mcpArgs: ['mcp'],
|
|
281
|
+
mcpAttach: { flag: '--connect', format: 'hostPort' },
|
|
275
282
|
},
|
|
276
283
|
{
|
|
277
284
|
id: 'effectcraft',
|
|
@@ -297,6 +304,7 @@ export const CRAFT_PROFILES = [
|
|
|
297
304
|
catalogJson: false,
|
|
298
305
|
catalogArgs: ['commands', '--json'],
|
|
299
306
|
mcpArgs: ['mcp'],
|
|
307
|
+
mcpAttach: { flag: '--bridge', format: 'port' },
|
|
300
308
|
},
|
|
301
309
|
{
|
|
302
310
|
id: 'filmcraft',
|
|
@@ -334,6 +342,7 @@ export const CRAFT_PROFILES = [
|
|
|
334
342
|
describeArgs: ['describe'],
|
|
335
343
|
catalogArgs: ['commands', '--json'],
|
|
336
344
|
mcpArgs: ['mcp'],
|
|
345
|
+
mcpAttach: { flag: '--bridge', format: 'hostPort' },
|
|
337
346
|
},
|
|
338
347
|
{
|
|
339
348
|
id: 'designcraft',
|
|
@@ -358,6 +367,7 @@ export const CRAFT_PROFILES = [
|
|
|
358
367
|
catalogJson: true,
|
|
359
368
|
catalogArgs: ['commands'],
|
|
360
369
|
mcpArgs: ['mcp'],
|
|
370
|
+
mcpAttach: { flag: '--connect', format: 'port' },
|
|
361
371
|
},
|
|
362
372
|
{
|
|
363
373
|
id: 'figcraft',
|
|
@@ -385,6 +395,7 @@ export const CRAFT_PROFILES = [
|
|
|
385
395
|
catalogJson: true,
|
|
386
396
|
catalogArgs: ['commands', '--json'],
|
|
387
397
|
mcpArgs: ['mcp'],
|
|
398
|
+
mcpAttach: { flag: '--attach', format: 'port' },
|
|
388
399
|
},
|
|
389
400
|
{
|
|
390
401
|
id: 'texturecraft',
|
|
@@ -415,6 +426,7 @@ export const CRAFT_PROFILES = [
|
|
|
415
426
|
catalogJson: false,
|
|
416
427
|
catalogArgs: ['commands'],
|
|
417
428
|
mcpArgs: ['mcp'],
|
|
429
|
+
mcpAttach: { flag: '--bridge', format: 'port' },
|
|
418
430
|
},
|
|
419
431
|
]
|
|
420
432
|
|
package/src/kit/tools.js
CHANGED
|
@@ -12,11 +12,11 @@ import { CraftMcpClient, renderSchema } from './mcp.js'
|
|
|
12
12
|
|
|
13
13
|
import { groupsOf, loadCatalog, score } from './catalog.js'
|
|
14
14
|
import { craftVersion, discoverCraft, missingBinaryMessage } from './discover.js'
|
|
15
|
-
import { mkdirSync, writeFileSync } from 'node:fs'
|
|
15
|
+
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
16
16
|
import { tmpdir } from 'node:os'
|
|
17
17
|
import { dirname, join } from 'node:path'
|
|
18
|
-
import { DEFAULT_TIMEOUT_MS, buildRunArgv, runCraft } from './exec.js'
|
|
19
|
-
|
|
18
|
+
import { DEFAULT_TIMEOUT_MS, buildRunArgv, killTree, runCraft, startCraftJob } from './exec.js'
|
|
19
|
+
|
|
20
20
|
|
|
21
21
|
|
|
22
22
|
/**
|
|
@@ -157,23 +157,83 @@ function craftWorkDir(profile ) {
|
|
|
157
157
|
*/
|
|
158
158
|
export function createRuntime(profile , packageRoot , config = {}) {
|
|
159
159
|
let catalogCache
|
|
160
|
-
|
|
160
|
+
const mcpClients = new Map ()
|
|
161
|
+
const artifacts = config.artifactsDir ?? join(tmpdir(), 'dsh-artcraft', profile.id)
|
|
162
|
+
const jobs = new Map ()
|
|
163
|
+
const jobChildren = new Map ()
|
|
164
|
+
let jobCounter = 0
|
|
161
165
|
return {
|
|
166
|
+
startJob(binary, argv) {
|
|
167
|
+
jobCounter += 1
|
|
168
|
+
const id = `j${jobCounter}`
|
|
169
|
+
const jobDir = join(artifacts, 'jobs')
|
|
170
|
+
mkdirSync(jobDir, { recursive: true })
|
|
171
|
+
const logPath = join(jobDir, `${profile.id}-${id}.log`)
|
|
172
|
+
const started = startCraftJob({ profile, binary, argv, cwd: process.cwd(), logPath, id })
|
|
173
|
+
const state = { ...started, running: true, argv: started.argv, logPath, id, pid: started.pid, startedAt: started.startedAt }
|
|
174
|
+
jobs.set(id, state)
|
|
175
|
+
jobChildren.set(id, started.child)
|
|
176
|
+
started.child.on('close', (code, signal) => {
|
|
177
|
+
const settled = {
|
|
178
|
+
...state, running: false, exitCode: code, signal,
|
|
179
|
+
durationMs: Date.now() - state.startedAt,
|
|
180
|
+
}
|
|
181
|
+
jobs.set(id, settled)
|
|
182
|
+
jobChildren.delete(id)
|
|
183
|
+
})
|
|
184
|
+
started.child.unref()
|
|
185
|
+
return started
|
|
186
|
+
},
|
|
187
|
+
jobState(id) {
|
|
188
|
+
return jobs.get(id)
|
|
189
|
+
},
|
|
190
|
+
jobs() {
|
|
191
|
+
return [...jobs.values()].sort((a, b) => a.startedAt - b.startedAt)
|
|
192
|
+
},
|
|
193
|
+
async waitForJob(id, timeoutMs) {
|
|
194
|
+
const state = jobs.get(id)
|
|
195
|
+
if (state === undefined) return undefined
|
|
196
|
+
const deadline = Date.now() + Math.max(0, timeoutMs)
|
|
197
|
+
for (;;) {
|
|
198
|
+
const current = jobs.get(id)
|
|
199
|
+
if (current === undefined || !current.running) return current
|
|
200
|
+
if (Date.now() >= deadline) return current
|
|
201
|
+
await new Promise(resolve => setTimeout(resolve, 200))
|
|
202
|
+
}
|
|
203
|
+
},
|
|
204
|
+
killJob(id) {
|
|
205
|
+
const child = jobChildren.get(id)
|
|
206
|
+
if (child !== undefined) killTree(child)
|
|
207
|
+
return child !== undefined
|
|
208
|
+
},
|
|
209
|
+
artifactsDir() {
|
|
210
|
+
return artifacts
|
|
211
|
+
},
|
|
162
212
|
profile,
|
|
163
213
|
config,
|
|
164
|
-
mcp() {
|
|
214
|
+
mcp(attach ) {
|
|
165
215
|
if (config.enableMcp === false) return undefined
|
|
166
216
|
if (profile.mcpArgs === undefined) return undefined
|
|
217
|
+
if (attach !== undefined && profile.mcpAttach === undefined) {
|
|
218
|
+
throw new Error(`${profile.product} cannot attach to a running app: its MCP server is headless only`)
|
|
219
|
+
}
|
|
167
220
|
const binary = this.binary()
|
|
168
221
|
if (binary === undefined) return undefined
|
|
222
|
+
const args = [...profile.mcpSessionArgs ?? []]
|
|
223
|
+
if (attach !== undefined && profile.mcpAttach !== undefined) {
|
|
224
|
+
args.push(profile.mcpAttach.flag, profile.mcpAttach.format === 'hostPort' ? `127.0.0.1:${attach}` : String(attach))
|
|
225
|
+
}
|
|
226
|
+
const key = String(attach ?? '')
|
|
227
|
+
const existing = mcpClients.get(key)
|
|
169
228
|
// A CLI upgrade mid-session invalidates the running server.
|
|
170
|
-
if (
|
|
171
|
-
|
|
172
|
-
|
|
229
|
+
if (existing !== undefined && !existing.running && existing.cachedTools !== undefined) mcpClients.delete(key)
|
|
230
|
+
const fresh = mcpClients.get(key) ?? new CraftMcpClient(profile, binary, args)
|
|
231
|
+
mcpClients.set(key, fresh)
|
|
232
|
+
return fresh
|
|
173
233
|
},
|
|
174
234
|
dispose() {
|
|
175
|
-
|
|
176
|
-
|
|
235
|
+
for (const client of mcpClients.values()) client.close()
|
|
236
|
+
mcpClients.clear()
|
|
177
237
|
},
|
|
178
238
|
binary() {
|
|
179
239
|
const found = discoverCraft(profile, config.path, packageRoot)
|
|
@@ -250,6 +310,8 @@ export function renderCatalog(
|
|
|
250
310
|
|
|
251
311
|
|
|
252
312
|
|
|
313
|
+
|
|
314
|
+
|
|
253
315
|
|
|
254
316
|
|
|
255
317
|
/** One tool's definition: name/description/schema plus its executor. */
|
|
@@ -308,6 +370,25 @@ export function createToolSet(runtime ) {
|
|
|
308
370
|
const timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS
|
|
309
371
|
const catalogLimit = config.catalogLimit ?? 60
|
|
310
372
|
|
|
373
|
+
/**
|
|
374
|
+
* Run now, or start a background job when `background` is set.
|
|
375
|
+
*
|
|
376
|
+
* Renders, bakes and bounces outlast the tool timeout; a job keeps the process
|
|
377
|
+
* alive after the call returns and leaves its output in a log file.
|
|
378
|
+
*/
|
|
379
|
+
const runOrStart = async (binary , argv , background ) => {
|
|
380
|
+
if (background === true) {
|
|
381
|
+
const job = runtime.startJob(binary, argv)
|
|
382
|
+
return [
|
|
383
|
+
`started ${profile.id} job ${job.id}${job.pid === undefined ? '' : ` (pid ${job.pid})`}`,
|
|
384
|
+
`log: ${job.logPath}`,
|
|
385
|
+
`poll with \`${profile.id}_job\` {"action":"status","job":"${job.id}"}`,
|
|
386
|
+
`or wait with {"action":"wait","job":"${job.id}","timeoutMs":300000}`,
|
|
387
|
+
].join('\n')
|
|
388
|
+
}
|
|
389
|
+
return renderRun(await runCraft({ profile, binary, argv, cwd: process.cwd(), timeoutMs }), profile)
|
|
390
|
+
}
|
|
391
|
+
|
|
311
392
|
/** Guard every tool: a missing binary is an answer, never a crash. */
|
|
312
393
|
const requireBinary = () => {
|
|
313
394
|
const binary = runtime.binary()
|
|
@@ -321,13 +402,14 @@ export function createToolSet(runtime ) {
|
|
|
321
402
|
input ,
|
|
322
403
|
sample ,
|
|
323
404
|
commands ,
|
|
405
|
+
background ,
|
|
324
406
|
) => {
|
|
325
407
|
const argv = buildRunArgv(profile, {
|
|
326
408
|
...input !== undefined ? { input } : {},
|
|
327
409
|
...sample !== undefined ? { sample } : {},
|
|
328
410
|
commands,
|
|
329
411
|
})
|
|
330
|
-
return
|
|
412
|
+
return await runOrStart(binary, argv, background)
|
|
331
413
|
}
|
|
332
414
|
|
|
333
415
|
const session = { commands: [], stale: false }
|
|
@@ -359,6 +441,7 @@ export function createToolSet(runtime ) {
|
|
|
359
441
|
+ `${profile.blurb} Files: ${profile.extensions.join(', ')}.`,
|
|
360
442
|
parameters: {
|
|
361
443
|
input: { type: 'string', description: `Absolute or workspace-relative path to an existing ${profile.extensions[0]} document. Mutually exclusive with \`sample\`.` },
|
|
444
|
+
background: { type: 'boolean', description: 'Start the work as a background job and return immediately; poll it with the job tool. Use for long renders, bakes and bounces.' },
|
|
362
445
|
commands: {
|
|
363
446
|
type: 'array',
|
|
364
447
|
description: 'Ordered commands to apply. Each is { "id": "cell.set", "json": { "cell": "A1", "input": "x" } }. An entry may also be a raw CLI string.',
|
|
@@ -452,8 +535,7 @@ export function createToolSet(runtime ) {
|
|
|
452
535
|
}
|
|
453
536
|
writeFileSync(scriptPath, typeof body === 'string' ? body : JSON.stringify(body, null, 2) + '\n')
|
|
454
537
|
const extra = Array.isArray(args.extraArgs) ? args.extraArgs.filter((a) => typeof a === 'string') : []
|
|
455
|
-
|
|
456
|
-
return renderRun(scriptResult, profile)
|
|
538
|
+
return await runOrStart(binary, [...argv, ...extra], args.background)
|
|
457
539
|
}
|
|
458
540
|
if (profile.run.scriptFlag !== undefined) {
|
|
459
541
|
const scriptPath = join(craftWorkDir(profile), `steps-${Date.now()}.json`)
|
|
@@ -468,8 +550,7 @@ export function createToolSet(runtime ) {
|
|
|
468
550
|
profile.run.scriptFlag, scriptPath,
|
|
469
551
|
...Array.isArray(args.extraArgs) ? args.extraArgs.filter((a) => typeof a === 'string') : [],
|
|
470
552
|
]
|
|
471
|
-
|
|
472
|
-
return renderRun(result, profile)
|
|
553
|
+
return await runOrStart(binary, [profile.run.verb, ...extra], args.background)
|
|
473
554
|
}
|
|
474
555
|
if (profile.run.scriptMode === true) {
|
|
475
556
|
const scriptPath = join(craftWorkDir(profile), `run-${Date.now()}.jsonl`)
|
|
@@ -481,12 +562,7 @@ export function createToolSet(runtime ) {
|
|
|
481
562
|
scriptPath,
|
|
482
563
|
...Array.isArray(args.extraArgs) ? args.extraArgs.filter((a) => typeof a === 'string') : [],
|
|
483
564
|
]
|
|
484
|
-
|
|
485
|
-
profile, binary,
|
|
486
|
-
argv: [...globalFlagsFor(profile, args), profile.run.verb, ...extra],
|
|
487
|
-
cwd: process.cwd(), timeoutMs,
|
|
488
|
-
})
|
|
489
|
-
return renderRun(result, profile)
|
|
565
|
+
return await runOrStart(binary, [...globalFlagsFor(profile, args), profile.run.verb, ...extra], args.background)
|
|
490
566
|
}
|
|
491
567
|
// PdfCraft's `run` accepts exactly one tool per invocation; sending a
|
|
492
568
|
// list would produce "expected key=value" from the CLI, so the extra
|
|
@@ -497,7 +573,7 @@ export function createToolSet(runtime ) {
|
|
|
497
573
|
// whole list in a single process; the history recorded above is replayed
|
|
498
574
|
// through that path, so a second call continues the same document.
|
|
499
575
|
if (profile.oneToolPerRun === true && profile.run.scriptFlag === undefined && rest.length > 0) {
|
|
500
|
-
const only = await runSingle(binary, input, sample, single)
|
|
576
|
+
const only = await runSingle(binary, input, sample, single, args.background)
|
|
501
577
|
return `${only}\n\n[${profile.product} runs one tool per call: ${rest.length} further command(s) were not sent — ${rest.map(c => c.id).join(', ')}. Call this tool again for each.]`
|
|
502
578
|
}
|
|
503
579
|
const argv = buildRunArgv(profile, {
|
|
@@ -515,8 +591,7 @@ export function createToolSet(runtime ) {
|
|
|
515
591
|
...args.print === true ? { print: true } : {},
|
|
516
592
|
...Array.isArray(args.extraArgs) ? { extra: args.extraArgs.filter((a) => typeof a === 'string') } : {},
|
|
517
593
|
})
|
|
518
|
-
|
|
519
|
-
return renderRun(result, profile)
|
|
594
|
+
return await runOrStart(binary, argv, args.background)
|
|
520
595
|
},
|
|
521
596
|
}
|
|
522
597
|
|
|
@@ -630,8 +705,7 @@ export function createToolSet(runtime ) {
|
|
|
630
705
|
else if (argMode === 'section') argv.push(typeof args.section === 'string' && args.section.length > 0 ? args.section : inspectSpec.section ?? 'project')
|
|
631
706
|
if (inspectSpec.projectFlag !== undefined && typeof args.project === 'string') argv.push(inspectSpec.projectFlag, args.project)
|
|
632
707
|
argv.push(...inspectSpec.extraArgs ?? [], ...extra)
|
|
633
|
-
|
|
634
|
-
return renderRun(result, profile)
|
|
708
|
+
return await runOrStart(binary, argv, args.background)
|
|
635
709
|
},
|
|
636
710
|
}
|
|
637
711
|
|
|
@@ -656,6 +730,69 @@ export function createToolSet(runtime ) {
|
|
|
656
730
|
},
|
|
657
731
|
} : undefined
|
|
658
732
|
|
|
733
|
+
const job = {
|
|
734
|
+
kind: 'job',
|
|
735
|
+
name: `${profile.id}_job`,
|
|
736
|
+
description:
|
|
737
|
+
`Track background work started by \`${profile.id}_run\` with \`background: true\`. `
|
|
738
|
+
+ `Long renders, bakes and bounces outlive a tool timeout; the job keeps running and logs to a file. `
|
|
739
|
+
+ `Actions: \`list\` every job, \`status\` one (with the log tail), \`wait\` for it to finish, \`kill\` it.`,
|
|
740
|
+
parameters: {
|
|
741
|
+
action: { type: 'string', enum: ['list', 'status', 'wait', 'kill'], description: 'What to do. Defaults to list.' },
|
|
742
|
+
job: { type: 'string', description: 'Job id returned by the run call, e.g. "j1".' },
|
|
743
|
+
timeoutMs: { type: 'number', description: 'For action "wait": milliseconds to block (default 60000, at most 600000).' },
|
|
744
|
+
tailLines: { type: 'number', description: 'For "status"/"wait": how many log lines to show (default 20).' },
|
|
745
|
+
},
|
|
746
|
+
async execute(args) {
|
|
747
|
+
const action = typeof args.action === 'string' ? args.action : 'list'
|
|
748
|
+
const tailLines = typeof args.tailLines === 'number' && args.tailLines > 0 ? Math.min(Math.floor(args.tailLines), 200) : 20
|
|
749
|
+
/** Last lines of a job's log, with a byte cap so a huge log cannot flood the model. */
|
|
750
|
+
const tail = (path ) => {
|
|
751
|
+
try {
|
|
752
|
+
const text = readFileSync(path, 'utf8')
|
|
753
|
+
const lines = text.split('\n')
|
|
754
|
+
const shown = lines.slice(Math.max(0, lines.length - tailLines)).join('\n').trim()
|
|
755
|
+
return shown.length > 8000 ? `…${shown.slice(-8000)}` : shown
|
|
756
|
+
} catch {
|
|
757
|
+
return '(no log yet)'
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
/** A job's argv, shortened: a 200-command render would otherwise print pages. */
|
|
761
|
+
const commandLine = (argv ) => {
|
|
762
|
+
const text = argv.join(' ')
|
|
763
|
+
return text.length > 240 ? `${text.slice(0, 240)}… (${argv.length} arguments)` : text
|
|
764
|
+
}
|
|
765
|
+
const describe = (state ) => [
|
|
766
|
+
`${state.id}${state.running ? ' (running)' : ` (exit ${state.exitCode ?? state.signal ?? '?'})`}`
|
|
767
|
+
+ ` — ${state.durationMs === undefined ? `${Math.round((Date.now() - state.startedAt) / 1000)}s so far` : `${Math.round(state.durationMs / 1000)}s`}`,
|
|
768
|
+
` command: ${commandLine(state.argv)}`,
|
|
769
|
+
` log: ${state.logPath}`,
|
|
770
|
+
].join('\n')
|
|
771
|
+
|
|
772
|
+
if (action === 'list') {
|
|
773
|
+
const all = runtime.jobs()
|
|
774
|
+
if (all.length === 0) return `${profile.product}: no background jobs in this session.`
|
|
775
|
+
return [`${profile.product} background jobs: ${all.length}`, ...all.map(describe)].join('\n')
|
|
776
|
+
}
|
|
777
|
+
const id = typeof args.job === 'string' ? args.job.trim() : ''
|
|
778
|
+
if (id.length === 0) throw new Error('job is required for action ' + JSON.stringify(action))
|
|
779
|
+
if (action === 'kill') {
|
|
780
|
+
const killed = runtime.killJob(id)
|
|
781
|
+
const state = runtime.jobState(id)
|
|
782
|
+
return killed ? `killed ${id}` : `${id} is not running${state === undefined ? ' (unknown job)' : ''}`
|
|
783
|
+
}
|
|
784
|
+
if (action === 'wait') {
|
|
785
|
+
const timeout = typeof args.timeoutMs === 'number' && args.timeoutMs > 0 ? Math.min(Math.floor(args.timeoutMs), 600_000) : 60_000
|
|
786
|
+
const state = await runtime.waitForJob(id, timeout)
|
|
787
|
+
if (state === undefined) return `No job named ${JSON.stringify(id)}.`
|
|
788
|
+
return [describe(state), '', tail(state.logPath)].join('\n')
|
|
789
|
+
}
|
|
790
|
+
const state = runtime.jobState(id)
|
|
791
|
+
if (state === undefined) return `No job named ${JSON.stringify(id)}.`
|
|
792
|
+
return [describe(state), '', tail(state.logPath)].join('\n')
|
|
793
|
+
},
|
|
794
|
+
}
|
|
795
|
+
|
|
659
796
|
const mcp = runtime.mcp()
|
|
660
797
|
const idleMs = config.mcpIdleMs ?? 300_000
|
|
661
798
|
|
|
@@ -666,13 +803,19 @@ export function createToolSet(runtime ) {
|
|
|
666
803
|
`${profile.product}'s own MCP server tools, each with a real JSON Schema. `
|
|
667
804
|
+ `These are the modelled operations (think \`write_range\`, \`render\`), whereas \`${profile.id}_run\` takes generic `
|
|
668
805
|
+ `command strings. Use this to discover them, then call one with \`${profile.id}_mcp_call\`. `
|
|
669
|
-
+ `Search by name or description
|
|
806
|
+
+ `Search by name or description, page with \`offset\`/\`limit\` (large servers declare hundreds). `
|
|
807
|
+
+ `Also lists the server's MCP resources and prompts when it has them.`,
|
|
670
808
|
parameters: {
|
|
671
809
|
search: { type: 'string', description: 'Case-insensitive filter over tool names and descriptions.' },
|
|
672
|
-
limit: { type: 'number', description: 'Maximum tools to describe (default 30).' },
|
|
810
|
+
limit: { type: 'number', description: 'Maximum tools to describe (default 30, at most 200).' },
|
|
811
|
+
offset: { type: 'number', description: 'Skip this many matches, to page through a long list.' },
|
|
812
|
+
...profile.mcpAttach !== undefined
|
|
813
|
+
? { attach: { type: 'number', description: `Control-channel port of a running ${profile.product} app, to drive the user's open document instead of a headless one.` } }
|
|
814
|
+
: {},
|
|
673
815
|
},
|
|
674
816
|
async execute(args) {
|
|
675
|
-
const
|
|
817
|
+
const attach = typeof args.attach === 'number' ? Math.floor(args.attach) : undefined
|
|
818
|
+
const client = runtime.mcp(attach)
|
|
676
819
|
if (client === undefined) throw new Error(`${profile.product} has no MCP server configured`)
|
|
677
820
|
await client.start(idleMs)
|
|
678
821
|
const all = await client.listTools()
|
|
@@ -681,10 +824,11 @@ export function createToolSet(runtime ) {
|
|
|
681
824
|
? all
|
|
682
825
|
: all.filter(tool => tool.name.toLowerCase().includes(needle) || (tool.description ?? '').toLowerCase().includes(needle))
|
|
683
826
|
const limit = typeof args.limit === 'number' && args.limit > 0 ? Math.min(Math.floor(args.limit), 200) : 30
|
|
684
|
-
const
|
|
827
|
+
const offset = typeof args.offset === 'number' && args.offset > 0 ? Math.floor(args.offset) : 0
|
|
828
|
+
const shown = matched.slice(offset, offset + limit)
|
|
685
829
|
const lines = [
|
|
686
830
|
`${profile.product} MCP server — ${matched.length} tool(s)${needle.length > 0 ? ` matching ${JSON.stringify(args.search)}` : ''}`
|
|
687
|
-
+ (matched.length > shown.length ? `, showing ${shown.length}` : ''),
|
|
831
|
+
+ (matched.length > offset + shown.length ? `, showing ${offset}–${offset + shown.length}` : ''),
|
|
688
832
|
`server: ${client.describe()}`,
|
|
689
833
|
'',
|
|
690
834
|
]
|
|
@@ -693,6 +837,17 @@ export function createToolSet(runtime ) {
|
|
|
693
837
|
lines.push(renderSchema(tool.inputSchema))
|
|
694
838
|
}
|
|
695
839
|
if (shown.length === 0) lines.push('(no matching tools)')
|
|
840
|
+
// Resources and prompts are the parts a tool-only bridge would miss: the
|
|
841
|
+
// document, the command catalogue, and curated multi-step workflows.
|
|
842
|
+
const [resources, prompts] = await Promise.all([client.listResources(), client.listPrompts()])
|
|
843
|
+
if (resources.length > 0) {
|
|
844
|
+
lines.push('', 'resources:')
|
|
845
|
+
for (const resource of resources) lines.push(` ${resource.uri}${resource.name !== undefined ? ` — ${resource.name}` : ''}`)
|
|
846
|
+
}
|
|
847
|
+
if (prompts.length > 0) {
|
|
848
|
+
lines.push('', 'prompts:')
|
|
849
|
+
for (const prompt of prompts) lines.push(` ${prompt.name}${prompt.description !== undefined ? ` — ${prompt.description}` : ''}`)
|
|
850
|
+
}
|
|
696
851
|
return lines.join('\n')
|
|
697
852
|
},
|
|
698
853
|
}
|
|
@@ -703,13 +858,18 @@ export function createToolSet(runtime ) {
|
|
|
703
858
|
description:
|
|
704
859
|
`Call one tool on ${profile.product}'s MCP server with JSON arguments. `
|
|
705
860
|
+ `Discover names and schemas with \`${profile.id}_mcp_tools\` first. `
|
|
706
|
-
+ `Use this for modelled operations; use \`${profile.id}_run\` for arbitrary command ids
|
|
861
|
+
+ `Use this for modelled operations; use \`${profile.id}_run\` for arbitrary command ids. `
|
|
862
|
+
+ `Images a tool returns (render, screenshot) are saved to disk and named by path — look at them with \`read_image\`.`,
|
|
707
863
|
parameters: {
|
|
708
864
|
tool: { type: 'string', required: true, description: 'MCP tool name, exactly as listed.' },
|
|
709
865
|
arguments: { type: 'object', additionalProperties: true, description: "The tool's arguments object; see its schema in the listing." },
|
|
866
|
+
...profile.mcpAttach !== undefined
|
|
867
|
+
? { attach: { type: 'number', description: `Control-channel port of a running ${profile.product} app, to drive the user's open document instead of a headless one.` } }
|
|
868
|
+
: {},
|
|
710
869
|
},
|
|
711
870
|
async execute(args) {
|
|
712
|
-
const
|
|
871
|
+
const attach = typeof args.attach === 'number' ? Math.floor(args.attach) : undefined
|
|
872
|
+
const client = runtime.mcp(attach)
|
|
713
873
|
if (client === undefined) throw new Error(`${profile.product} has no MCP server configured`)
|
|
714
874
|
const name = String(args.tool ?? '').trim()
|
|
715
875
|
if (name.length === 0) throw new Error('tool is required')
|
|
@@ -722,7 +882,7 @@ export function createToolSet(runtime ) {
|
|
|
722
882
|
}
|
|
723
883
|
const raw = args.arguments
|
|
724
884
|
const params = typeof raw === 'object' && raw !== null && !Array.isArray(raw) ? raw : {}
|
|
725
|
-
return await client.callTool(name, params)
|
|
885
|
+
return await client.callTool(name, params, runtime.artifactsDir())
|
|
726
886
|
},
|
|
727
887
|
}
|
|
728
888
|
|
|
@@ -739,6 +899,9 @@ export function createToolSet(runtime ) {
|
|
|
739
899
|
`command: ${client.describe()}`,
|
|
740
900
|
`running: ${client.running}`,
|
|
741
901
|
`tools cached: ${client.cachedTools === undefined ? 'no' : client.cachedTools.length}`,
|
|
902
|
+
profile.mcpAttach === undefined
|
|
903
|
+
? 'attach: not supported (headless MCP server only)'
|
|
904
|
+
: `attach: pass \`attach: <port>\` to drive a running app (it must be started with its control channel, e.g. \`${profile.bin.replace(/-cli$/, '')} --control <port>\`)`,
|
|
742
905
|
].join('\n')
|
|
743
906
|
},
|
|
744
907
|
}
|
|
@@ -750,6 +913,7 @@ export function createToolSet(runtime ) {
|
|
|
750
913
|
...mcpTools !== undefined ? { mcpTools } : {},
|
|
751
914
|
...mcpCall !== undefined ? { mcpCall } : {},
|
|
752
915
|
...mcpStatus !== undefined ? { mcpStatus } : {},
|
|
916
|
+
job,
|
|
753
917
|
}
|
|
754
918
|
}
|
|
755
919
|
|
|
@@ -825,11 +989,17 @@ export function applyCraftPlugin(
|
|
|
825
989
|
}
|
|
826
990
|
ctx.systemPrompt.section({
|
|
827
991
|
name: `artcraft:${profile.id}`,
|
|
828
|
-
text:
|
|
829
|
-
|
|
830
|
-
+ `
|
|
831
|
-
+
|
|
832
|
-
+
|
|
992
|
+
text:
|
|
993
|
+
`${profile.product} is available through the ${profile.id}_* tools; documents are ${profile.extensions.join('/')}. `
|
|
994
|
+
+ `Two surfaces reach the same engine. Use \`${profile.id}_commands\` for the full command registry `
|
|
995
|
+
+ `(every menu action; fetch exact parameter grammar with \`${profile.id}_describe\`) and \`${profile.id}_run\` to apply `
|
|
996
|
+
+ `an ordered list. Use \`${profile.id}_mcp_tools\` for the app's modelled operations with JSON Schemas when you want `
|
|
997
|
+
+ `a safer first call, then \`${profile.id}_mcp_call\`. `
|
|
998
|
+
+ `Read a document back with \`${profile.id}_inspect\` before editing it. `
|
|
999
|
+
+ (profile.mcpAttach === undefined
|
|
1000
|
+
? ''
|
|
1001
|
+
: `When the user has ${profile.product} open, pass \`attach: <control port>\` to an mcp call to work in their window instead of a headless copy. `)
|
|
1002
|
+
+ `Images returned by a render or screenshot are saved and named by path; look at them with \`read_image\`.`,
|
|
833
1003
|
order: CRAFT_SECTION_ORDER,
|
|
834
1004
|
})
|
|
835
1005
|
return () => {
|