dsh-artcraft-plugin-gridcraft 0.2.1 → 0.2.4

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-artcraft-plugin-gridcraft",
3
- "version": "0.2.1",
3
+ "version": "0.2.4",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "DSH plugin: drive GridCraft (spreadsheets) headlessly through its own CLI and MCP server.",
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
  *
@@ -0,0 +1,55 @@
1
+ /**
2
+ * A readable work journal for the craft tools.
3
+ *
4
+ * The harness shows each tool call, but a session that builds a report spans a
5
+ * dozen calls across four applications, and reconstructing what actually happened
6
+ * means scrolling a transcript. Every call also appends one line to a per-craft
7
+ * journal in the workspace (`.artcraft/journal/`), so the user can open one file
8
+ * and read the story: which craft, which operation, how long, what came out.
9
+ *
10
+ * A journal line is best-effort and never fails a tool call: a read-only
11
+ * workspace loses the record, not the work.
12
+ * @module @dsh-artcraft/craft-kit/journal
13
+ */
14
+
15
+ import { appendFileSync, mkdirSync } from 'node:fs'
16
+ import { join } from 'node:path'
17
+
18
+
19
+ /** One recorded event. */
20
+
21
+
22
+
23
+  
24
+
25
+  
26
+
27
+
28
+
29
+
30
+
31
+ /** Where the journals live, relative to the workspace the agent runs in. */
32
+ export function journalDir(cwd = process.cwd()) {
33
+ return join(cwd, '.artcraft', 'journal')
34
+ }
35
+
36
+ /**
37
+ * Append one entry to the craft's journal.
38
+ * @param profile - the craft that ran.
39
+ * @param entry - what happened.
40
+ * @param cwd - workspace root; defaults to the process cwd.
41
+ */
42
+ export function appendJournal(profile , entry , cwd = process.cwd()) {
43
+ try {
44
+ const dir = journalDir(cwd)
45
+ mkdirSync(dir, { recursive: true })
46
+ const time = new Date(entry.at).toTimeString().slice(0, 8)
47
+ const artifacts = entry.artifacts !== undefined && entry.artifacts.length > 0
48
+ ? `\n → ${entry.artifacts.join(', ')}`
49
+ : ''
50
+ appendFileSync(join(dir, `${profile.id}.md`), `${time} · ${entry.kind} · ${entry.summary}${artifacts}\n`)
51
+ appendFileSync(join(dir, 'events.jsonl'), JSON.stringify({ craft: profile.id, ...entry }) + '\n')
52
+ } catch {
53
+ // A journal is a convenience; never let it fail the call that produced it.
54
+ }
55
+ }
@@ -0,0 +1,206 @@
1
+ /**
2
+ * Render intents for the craft tools: what a call looks like *while running* and
3
+ * what its result looks like afterwards.
4
+ *
5
+ * A craft run is one call that performs a dozen steps, and a raw JSONL dump is a
6
+ * poor card: the reader cannot see how many steps there are, which one failed, or
7
+ * what files came out. These presenters turn the same result into a titled card
8
+ * (a UI that understands {@link ToolCallView}/{@link ToolResultView} shows it as a
9
+ * tool row with a header and a short body).
10
+ *
11
+ * The kernel stays DSH-free: these are plain objects shaped like the harness's
12
+ * view contracts, and the calling plugin passes them to `defineTool`.
13
+ * @module @dsh-artcraft/craft-kit/present
14
+ */
15
+
16
+
17
+
18
+ /** One text block, as the harness's content-block shape expects. */
19
+
20
+
21
+
22
+
23
+
24
+ /** A file the call touches, for editor follow-along. */
25
+
26
+
27
+
28
+
29
+
30
+ /** Pending-call card: `{card:'generic'}` with a title and an optional body. */
31
+
32
+
33
+
34
+
35
+
36
+
37
+
38
+
39
+
40
+ /** Completed-call card: a replacement title and reformatted content. */
41
+
42
+
43
+
44
+
45
+
46
+
47
+ /** Arguments the model passed, as far as these presenters care. */
48
+
49
+
50
+ /** First `count` entries of a list, plus a count when longer. */
51
+ function summarise (items , render , count = 8) {
52
+ const shown = items.slice(0, count).map(render)
53
+ if (items.length > shown.length) shown.push(`…and ${items.length - shown.length} more`)
54
+ return shown.length === 0 ? [{ type: 'text', text: '(no commands)' }] : shown.map(text => ({ type: 'text', text }))
55
+ }
56
+
57
+ /** The command list a run call carries, whether objects or raw strings. */
58
+ function commandLabels(raw ) {
59
+ if (!Array.isArray(raw)) return []
60
+ return raw.map((entry) => {
61
+ if (typeof entry === 'string') return entry
62
+ if (typeof entry === 'object' && entry !== null) {
63
+ const record = entry
64
+ const id = typeof record.id === 'string' ? record.id : '(unnamed)'
65
+ const json = record.json === undefined ? '' : ` ${JSON.stringify(record.json)}`
66
+ return `${id}${json.length > 60 ? `${json.slice(0, 60)}…` : json}`
67
+ }
68
+ return String(entry)
69
+ })
70
+ }
71
+
72
+ /** A path from the arguments, when the call writes one. */
73
+ function outputPath(args ) {
74
+ for (const key of ['save', 'export', 'output', 'out'] ) {
75
+ const value = args[key]
76
+ if (typeof value === 'string' && value.length > 0) return value
77
+ }
78
+ return undefined
79
+ }
80
+
81
+ /** A path from the arguments, when the call reads one. */
82
+ function inputPath(args ) {
83
+ const value = args.input ?? args.file
84
+ return typeof value === 'string' && value.length > 0 ? value : undefined
85
+ }
86
+
87
+ /**
88
+ * The pending-call card for one tool.
89
+ * @param profile - the craft, for the title.
90
+ * @param kind - which tool of the set is being called.
91
+ * @param args - the model's arguments.
92
+ * @returns a generic call view.
93
+ */
94
+ export function craftCallView(profile , kind , args ) {
95
+ const name = profile.product
96
+ switch (kind) {
97
+ case 'run': {
98
+ const labels = commandLabels(args.commands)
99
+ const target = outputPath(args)
100
+ return {
101
+ card: 'generic',
102
+ kind: 'execute',
103
+ title: `${name} · ${labels.length} command${labels.length === 1 ? '' : 's'}${target === undefined ? '' : ` → ${target}`}`,
104
+ content: summarise(labels, label => `• ${label}`),
105
+ ...target === undefined ? {} : { locations: [{ path: target }] },
106
+ }
107
+ }
108
+ case 'commands': {
109
+ const search = typeof args.search === 'string' && args.search.length > 0 ? ` "${args.search}"` : ''
110
+ return { card: 'generic', kind: 'search', title: `${name} · command catalogue${search}` }
111
+ }
112
+ case 'describe': {
113
+ const id = typeof args.id === 'string' ? args.id : ''
114
+ return { card: 'generic', kind: 'read', title: `${name} · describe ${id}` }
115
+ }
116
+ case 'inspect': {
117
+ const file = inputPath(args) ?? '(default)'
118
+ return { card: 'generic', kind: 'read', title: `${name} · inspect ${file}`, locations: [{ path: file }] }
119
+ }
120
+ case 'convert': {
121
+ const input = inputPath(args) ?? ''
122
+ const output = typeof args.output === 'string' ? args.output : ''
123
+ return {
124
+ card: 'generic',
125
+ kind: 'execute',
126
+ title: `${name} · convert ${input}${output.length === 0 ? '' : ` → ${output}`}`,
127
+ locations: output.length === 0 ? [] : [{ path: output }],
128
+ }
129
+ }
130
+ case 'mcpCall': {
131
+ const tool = typeof args.tool === 'string' ? args.tool : '(tool)'
132
+ const attach = typeof args.attach === 'number' ? ` · attached :${args.attach}` : ''
133
+ return { card: 'generic', kind: 'execute', title: `${name} · ${tool}${attach}`, rawInput: args.arguments }
134
+ }
135
+ case 'mcpTools': {
136
+ const search = typeof args.search === 'string' && args.search.length > 0 ? ` "${args.search}"` : ''
137
+ return { card: 'generic', kind: 'search', title: `${name} · MCP tools, resources and prompts${search}` }
138
+ }
139
+ case 'mcpStatus':
140
+ return { card: 'generic', kind: 'read', title: `${name} · MCP bridge status` }
141
+ case 'job': {
142
+ const action = typeof args.action === 'string' ? args.action : 'list'
143
+ const job = typeof args.job === 'string' ? ` ${args.job}` : ''
144
+ return { card: 'generic', kind: 'other', title: `${name} · job ${action}${job}` }
145
+ }
146
+ default:
147
+ return { card: 'generic', kind: 'other', title: `${name} · ${kind}` }
148
+ }
149
+ }
150
+
151
+ /** What a run result text says: exit status, duration, artifacts, per-step failures. */
152
+ function summariseRun(profile , text ) {
153
+ const steps = text.split('\n').filter(line => line.startsWith('{"command"') || line.startsWith('{"step"'))
154
+ let ok = 0
155
+ let failed = 0
156
+ let firstFailure = ''
157
+ for (const line of steps) {
158
+ try {
159
+ const parsed = JSON.parse(line)
160
+ if (parsed.ok === false) {
161
+ failed += 1
162
+ if (firstFailure.length === 0) firstFailure = `${parsed.command ?? 'step'}: ${parsed.error ?? 'failed'}`
163
+ } else ok += 1
164
+ } catch {
165
+ // A craft that prints non-JSON progress lines still runs; the exit line below decides.
166
+ }
167
+ }
168
+ const exit = /exited (\d+) in (\d+)ms/.exec(text)
169
+ const artifacts = [...text.matchAll(/^(?:wrote|saved|exported) (.+)$/gm)].map(match => match[1])
170
+ const title = `${profile.product} · ${steps.length > 0 ? `${ok} ok${failed > 0 ? `, ${failed} failed` : ''}` : 'finished'}${exit === null ? '' : ` · exit ${exit[1]}`}`
171
+ const body = []
172
+ if (failed > 0) body.push(`first failure — ${firstFailure}`)
173
+ if (artifacts.length > 0) body.push(`wrote ${artifacts.join(', ')}`)
174
+ if (exit !== null) body.push(`took ${exit[2]} ms`)
175
+ if (body.length === 0) body.push(text.trim().split('\n')[0] ?? '(no output)')
176
+ return { card: 'generic', title, content: body.map(line => ({ type: 'text', text: line })) }
177
+ }
178
+
179
+ /**
180
+ * The completed-call card for one tool.
181
+ * @param profile - the craft, for the title.
182
+ * @param kind - which tool ran.
183
+ * @param text - the model-facing result.
184
+ * @param isError - whether the call failed.
185
+ * @returns a generic result view.
186
+ */
187
+ export function craftResultView(profile , kind , text , isError ) {
188
+ if (isError) {
189
+ return {
190
+ card: 'generic',
191
+ title: `${profile.product} · ${kind} failed`,
192
+ content: [{ type: 'text', text: text.split('\n').slice(0, 4).join('\n') }],
193
+ }
194
+ }
195
+ if (kind === 'run') return summariseRun(profile, text)
196
+ const all = text.trim().split('\n').filter(line => line.trim().length > 0)
197
+ // A few lines are usually the whole story (job status, an image path and its
198
+ // dimensions); keep them rather than collapsing to one line and a count.
199
+ const shown = all.slice(0, 4)
200
+ if (all.length > shown.length) shown.push(`…and ${all.length - shown.length} more lines`)
201
+ return {
202
+ card: 'generic',
203
+ title: `${profile.product} · ${kind}`,
204
+ content: (shown.length === 0 ? ['(no output)'] : shown).map(line => ({ type: 'text', text: line })),
205
+ }
206
+ }
package/src/kit/tools.js CHANGED
@@ -8,15 +8,17 @@
8
8
  * @module @dsh-artcraft/craft-kit/tools
9
9
  */
10
10
 
11
+ import { craftCallView, craftResultView } from './present.js'
12
+ import { appendJournal } from './journal.js'
11
13
  import { CraftMcpClient, renderSchema } from './mcp.js'
12
14
 
13
15
  import { groupsOf, loadCatalog, score } from './catalog.js'
14
16
  import { craftVersion, discoverCraft, missingBinaryMessage } from './discover.js'
15
- import { mkdirSync, writeFileSync } from 'node:fs'
17
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
16
18
  import { tmpdir } from 'node:os'
17
19
  import { dirname, join } from 'node:path'
18
- import { DEFAULT_TIMEOUT_MS, buildRunArgv, runCraft } from './exec.js'
19
-
20
+ import { DEFAULT_TIMEOUT_MS, buildRunArgv, killTree, runCraft, startCraftJob } from './exec.js'
21
+
20
22
 
21
23
 
22
24
  /**
@@ -159,7 +161,53 @@ export function createRuntime(profile , packageRoot , config
159
161
  let catalogCache
160
162
  const mcpClients = new Map ()
161
163
  const artifacts = config.artifactsDir ?? join(tmpdir(), 'dsh-artcraft', profile.id)
164
+ const jobs = new Map ()
165
+ const jobChildren = new Map ()
166
+ let jobCounter = 0
162
167
  return {
168
+ startJob(binary, argv) {
169
+ jobCounter += 1
170
+ const id = `j${jobCounter}`
171
+ const jobDir = join(artifacts, 'jobs')
172
+ mkdirSync(jobDir, { recursive: true })
173
+ const logPath = join(jobDir, `${profile.id}-${id}.log`)
174
+ const started = startCraftJob({ profile, binary, argv, cwd: process.cwd(), logPath, id })
175
+ const state = { ...started, running: true, argv: started.argv, logPath, id, pid: started.pid, startedAt: started.startedAt }
176
+ jobs.set(id, state)
177
+ jobChildren.set(id, started.child)
178
+ started.child.on('close', (code, signal) => {
179
+ const settled = {
180
+ ...state, running: false, exitCode: code, signal,
181
+ durationMs: Date.now() - state.startedAt,
182
+ }
183
+ jobs.set(id, settled)
184
+ jobChildren.delete(id)
185
+ })
186
+ started.child.unref()
187
+ return started
188
+ },
189
+ jobState(id) {
190
+ return jobs.get(id)
191
+ },
192
+ jobs() {
193
+ return [...jobs.values()].sort((a, b) => a.startedAt - b.startedAt)
194
+ },
195
+ async waitForJob(id, timeoutMs) {
196
+ const state = jobs.get(id)
197
+ if (state === undefined) return undefined
198
+ const deadline = Date.now() + Math.max(0, timeoutMs)
199
+ for (;;) {
200
+ const current = jobs.get(id)
201
+ if (current === undefined || !current.running) return current
202
+ if (Date.now() >= deadline) return current
203
+ await new Promise(resolve => setTimeout(resolve, 200))
204
+ }
205
+ },
206
+ killJob(id) {
207
+ const child = jobChildren.get(id)
208
+ if (child !== undefined) killTree(child)
209
+ return child !== undefined
210
+ },
163
211
  artifactsDir() {
164
212
  return artifacts
165
213
  },
@@ -264,15 +312,21 @@ export function renderCatalog(
264
312
 
265
313
 
266
314
 
315
+
316
+
267
317
 
268
318
 
269
- /** One tool's definition: name/description/schema plus its executor. */
319
+ /** One tool's definition: name/description/schema plus its executor and views. */
270
320
 
271
321
 
272
322
 
273
323
 
274
324
 
275
325
 
326
+
327
+
328
+
329
+
276
330
 
277
331
 
278
332
  /**
@@ -322,6 +376,25 @@ export function createToolSet(runtime ) {
322
376
  const timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS
323
377
  const catalogLimit = config.catalogLimit ?? 60
324
378
 
379
+ /**
380
+ * Run now, or start a background job when `background` is set.
381
+ *
382
+ * Renders, bakes and bounces outlast the tool timeout; a job keeps the process
383
+ * alive after the call returns and leaves its output in a log file.
384
+ */
385
+ const runOrStart = async (binary , argv , background ) => {
386
+ if (background === true) {
387
+ const job = runtime.startJob(binary, argv)
388
+ return [
389
+ `started ${profile.id} job ${job.id}${job.pid === undefined ? '' : ` (pid ${job.pid})`}`,
390
+ `log: ${job.logPath}`,
391
+ `poll with \`${profile.id}_job\` {"action":"status","job":"${job.id}"}`,
392
+ `or wait with {"action":"wait","job":"${job.id}","timeoutMs":300000}`,
393
+ ].join('\n')
394
+ }
395
+ return renderRun(await runCraft({ profile, binary, argv, cwd: process.cwd(), timeoutMs }), profile)
396
+ }
397
+
325
398
  /** Guard every tool: a missing binary is an answer, never a crash. */
326
399
  const requireBinary = () => {
327
400
  const binary = runtime.binary()
@@ -335,13 +408,14 @@ export function createToolSet(runtime ) {
335
408
  input ,
336
409
  sample ,
337
410
  commands ,
411
+ background ,
338
412
  ) => {
339
413
  const argv = buildRunArgv(profile, {
340
414
  ...input !== undefined ? { input } : {},
341
415
  ...sample !== undefined ? { sample } : {},
342
416
  commands,
343
417
  })
344
- return renderRun(await runCraft({ profile, binary, argv, cwd: process.cwd(), timeoutMs }), profile)
418
+ return await runOrStart(binary, argv, background)
345
419
  }
346
420
 
347
421
  const session = { commands: [], stale: false }
@@ -373,6 +447,7 @@ export function createToolSet(runtime ) {
373
447
  + `${profile.blurb} Files: ${profile.extensions.join(', ')}.`,
374
448
  parameters: {
375
449
  input: { type: 'string', description: `Absolute or workspace-relative path to an existing ${profile.extensions[0]} document. Mutually exclusive with \`sample\`.` },
450
+ 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.' },
376
451
  commands: {
377
452
  type: 'array',
378
453
  description: 'Ordered commands to apply. Each is { "id": "cell.set", "json": { "cell": "A1", "input": "x" } }. An entry may also be a raw CLI string.',
@@ -466,8 +541,7 @@ export function createToolSet(runtime ) {
466
541
  }
467
542
  writeFileSync(scriptPath, typeof body === 'string' ? body : JSON.stringify(body, null, 2) + '\n')
468
543
  const extra = Array.isArray(args.extraArgs) ? args.extraArgs.filter((a) => typeof a === 'string') : []
469
- const scriptResult = await runCraft({ profile, binary, argv: [...argv, ...extra], cwd: process.cwd(), timeoutMs })
470
- return renderRun(scriptResult, profile)
544
+ return await runOrStart(binary, [...argv, ...extra], args.background)
471
545
  }
472
546
  if (profile.run.scriptFlag !== undefined) {
473
547
  const scriptPath = join(craftWorkDir(profile), `steps-${Date.now()}.json`)
@@ -482,8 +556,7 @@ export function createToolSet(runtime ) {
482
556
  profile.run.scriptFlag, scriptPath,
483
557
  ...Array.isArray(args.extraArgs) ? args.extraArgs.filter((a) => typeof a === 'string') : [],
484
558
  ]
485
- const result = await runCraft({ profile, binary, argv: [profile.run.verb, ...extra], cwd: process.cwd(), timeoutMs })
486
- return renderRun(result, profile)
559
+ return await runOrStart(binary, [profile.run.verb, ...extra], args.background)
487
560
  }
488
561
  if (profile.run.scriptMode === true) {
489
562
  const scriptPath = join(craftWorkDir(profile), `run-${Date.now()}.jsonl`)
@@ -495,12 +568,7 @@ export function createToolSet(runtime ) {
495
568
  scriptPath,
496
569
  ...Array.isArray(args.extraArgs) ? args.extraArgs.filter((a) => typeof a === 'string') : [],
497
570
  ]
498
- const result = await runCraft({
499
- profile, binary,
500
- argv: [...globalFlagsFor(profile, args), profile.run.verb, ...extra],
501
- cwd: process.cwd(), timeoutMs,
502
- })
503
- return renderRun(result, profile)
571
+ return await runOrStart(binary, [...globalFlagsFor(profile, args), profile.run.verb, ...extra], args.background)
504
572
  }
505
573
  // PdfCraft's `run` accepts exactly one tool per invocation; sending a
506
574
  // list would produce "expected key=value" from the CLI, so the extra
@@ -511,7 +579,7 @@ export function createToolSet(runtime ) {
511
579
  // whole list in a single process; the history recorded above is replayed
512
580
  // through that path, so a second call continues the same document.
513
581
  if (profile.oneToolPerRun === true && profile.run.scriptFlag === undefined && rest.length > 0) {
514
- const only = await runSingle(binary, input, sample, single)
582
+ const only = await runSingle(binary, input, sample, single, args.background)
515
583
  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.]`
516
584
  }
517
585
  const argv = buildRunArgv(profile, {
@@ -529,8 +597,7 @@ export function createToolSet(runtime ) {
529
597
  ...args.print === true ? { print: true } : {},
530
598
  ...Array.isArray(args.extraArgs) ? { extra: args.extraArgs.filter((a) => typeof a === 'string') } : {},
531
599
  })
532
- const result = await runCraft({ profile, binary, argv, cwd: process.cwd(), timeoutMs })
533
- return renderRun(result, profile)
600
+ return await runOrStart(binary, argv, args.background)
534
601
  },
535
602
  }
536
603
 
@@ -644,8 +711,7 @@ export function createToolSet(runtime ) {
644
711
  else if (argMode === 'section') argv.push(typeof args.section === 'string' && args.section.length > 0 ? args.section : inspectSpec.section ?? 'project')
645
712
  if (inspectSpec.projectFlag !== undefined && typeof args.project === 'string') argv.push(inspectSpec.projectFlag, args.project)
646
713
  argv.push(...inspectSpec.extraArgs ?? [], ...extra)
647
- const result = await runCraft({ profile, binary, argv, cwd: process.cwd(), timeoutMs })
648
- return renderRun(result, profile)
714
+ return await runOrStart(binary, argv, args.background)
649
715
  },
650
716
  }
651
717
 
@@ -670,6 +736,69 @@ export function createToolSet(runtime ) {
670
736
  },
671
737
  } : undefined
672
738
 
739
+ const job = {
740
+ kind: 'job',
741
+ name: `${profile.id}_job`,
742
+ description:
743
+ `Track background work started by \`${profile.id}_run\` with \`background: true\`. `
744
+ + `Long renders, bakes and bounces outlive a tool timeout; the job keeps running and logs to a file. `
745
+ + `Actions: \`list\` every job, \`status\` one (with the log tail), \`wait\` for it to finish, \`kill\` it.`,
746
+ parameters: {
747
+ action: { type: 'string', enum: ['list', 'status', 'wait', 'kill'], description: 'What to do. Defaults to list.' },
748
+ job: { type: 'string', description: 'Job id returned by the run call, e.g. "j1".' },
749
+ timeoutMs: { type: 'number', description: 'For action "wait": milliseconds to block (default 60000, at most 600000).' },
750
+ tailLines: { type: 'number', description: 'For "status"/"wait": how many log lines to show (default 20).' },
751
+ },
752
+ async execute(args) {
753
+ const action = typeof args.action === 'string' ? args.action : 'list'
754
+ const tailLines = typeof args.tailLines === 'number' && args.tailLines > 0 ? Math.min(Math.floor(args.tailLines), 200) : 20
755
+ /** Last lines of a job's log, with a byte cap so a huge log cannot flood the model. */
756
+ const tail = (path ) => {
757
+ try {
758
+ const text = readFileSync(path, 'utf8')
759
+ const lines = text.split('\n')
760
+ const shown = lines.slice(Math.max(0, lines.length - tailLines)).join('\n').trim()
761
+ return shown.length > 8000 ? `…${shown.slice(-8000)}` : shown
762
+ } catch {
763
+ return '(no log yet)'
764
+ }
765
+ }
766
+ /** A job's argv, shortened: a 200-command render would otherwise print pages. */
767
+ const commandLine = (argv ) => {
768
+ const text = argv.join(' ')
769
+ return text.length > 240 ? `${text.slice(0, 240)}… (${argv.length} arguments)` : text
770
+ }
771
+ const describe = (state ) => [
772
+ `${state.id}${state.running ? ' (running)' : ` (exit ${state.exitCode ?? state.signal ?? '?'})`}`
773
+ + ` — ${state.durationMs === undefined ? `${Math.round((Date.now() - state.startedAt) / 1000)}s so far` : `${Math.round(state.durationMs / 1000)}s`}`,
774
+ ` command: ${commandLine(state.argv)}`,
775
+ ` log: ${state.logPath}`,
776
+ ].join('\n')
777
+
778
+ if (action === 'list') {
779
+ const all = runtime.jobs()
780
+ if (all.length === 0) return `${profile.product}: no background jobs in this session.`
781
+ return [`${profile.product} background jobs: ${all.length}`, ...all.map(describe)].join('\n')
782
+ }
783
+ const id = typeof args.job === 'string' ? args.job.trim() : ''
784
+ if (id.length === 0) throw new Error('job is required for action ' + JSON.stringify(action))
785
+ if (action === 'kill') {
786
+ const killed = runtime.killJob(id)
787
+ const state = runtime.jobState(id)
788
+ return killed ? `killed ${id}` : `${id} is not running${state === undefined ? ' (unknown job)' : ''}`
789
+ }
790
+ if (action === 'wait') {
791
+ const timeout = typeof args.timeoutMs === 'number' && args.timeoutMs > 0 ? Math.min(Math.floor(args.timeoutMs), 600_000) : 60_000
792
+ const state = await runtime.waitForJob(id, timeout)
793
+ if (state === undefined) return `No job named ${JSON.stringify(id)}.`
794
+ return [describe(state), '', tail(state.logPath)].join('\n')
795
+ }
796
+ const state = runtime.jobState(id)
797
+ if (state === undefined) return `No job named ${JSON.stringify(id)}.`
798
+ return [describe(state), '', tail(state.logPath)].join('\n')
799
+ },
800
+ }
801
+
673
802
  const mcp = runtime.mcp()
674
803
  const idleMs = config.mcpIdleMs ?? 300_000
675
804
 
@@ -790,10 +919,12 @@ export function createToolSet(runtime ) {
790
919
  ...mcpTools !== undefined ? { mcpTools } : {},
791
920
  ...mcpCall !== undefined ? { mcpCall } : {},
792
921
  ...mcpStatus !== undefined ? { mcpStatus } : {},
922
+ job,
793
923
  }
794
924
  }
795
925
 
796
926
 
927
+
797
928
  /** Prompt-section slot: after the built-in tool sections, app prose follows. */
798
929
  export const CRAFT_SECTION_ORDER = 120
799
930
 
@@ -810,8 +941,34 @@ export const CRAFT_SECTION_ORDER = 120
810
941
 
811
942
 
812
943
 
944
+
945
+
813
946
 
814
947
 
948
+ /** One-line journal summary for a finished call. */
949
+ function journalSummary(kind , args , text , elapsedMs ) {
950
+ const output = ['save', 'export', 'output', 'out'].map(key => args[key]).find(value => typeof value === 'string' && value.length > 0)
951
+ switch (kind) {
952
+ case 'run': {
953
+ const count = Array.isArray(args.commands) ? args.commands.length : 0
954
+ const exit = /exited (\d+)/.exec(text)
955
+ return `${count} command(s)${output === undefined ? '' : ` → ${String(output)}`}`
956
+ + `${exit === null ? '' : ` · exit ${exit[1]}`} · ${elapsedMs}ms`
957
+ }
958
+ case 'mcpCall':
959
+ return `tool ${String(args.tool ?? '?')} · ${elapsedMs}ms`
960
+ case 'job':
961
+ return `job ${String(args.action ?? 'list')}${typeof args.job === 'string' ? ` ${args.job}` : ''}`
962
+ case 'inspect':
963
+ case 'convert':
964
+ return `${String(args.file ?? args.input ?? '')}${args.output === undefined ? '' : ` → ${String(args.output)}`}`
965
+ default: {
966
+ const first = text.trim().split('\n')[0] ?? ''
967
+ return first.length > 120 ? `${first.slice(0, 120)}…` : first
968
+ }
969
+ }
970
+ }
971
+
815
972
  /** The `ctx` surface the kernel uses; kept structural for the same reason. */
816
973
 
817
974
 
@@ -859,8 +1016,32 @@ export function applyCraftPlugin(
859
1016
  render: (_args , value ) => [{ type: 'text' , text: value }],
860
1017
  },
861
1018
  async execute(args ) {
862
- return await spec.execute(args )
1019
+ const typed = args
1020
+ const startedAt = Date.now()
1021
+ try {
1022
+ const text = await spec.execute(typed)
1023
+ appendJournal(profile, { at: startedAt, kind: spec.kind, summary: journalSummary(spec.kind, typed, text, Date.now() - startedAt) })
1024
+ return text
1025
+ } catch (error) {
1026
+ appendJournal(profile, {
1027
+ at: startedAt,
1028
+ kind: spec.kind,
1029
+ summary: `failed: ${String((error ).message ?? error).slice(0, 140)}`,
1030
+ })
1031
+ throw error
1032
+ }
863
1033
  },
1034
+ // Cards: a run is one call with a dozen steps, so the raw JSONL is a poor
1035
+ // row. These turn it into a titled card — what the call does, then what it
1036
+ // produced — and the journal keeps the same story on disk.
1037
+ presentCall: (args ) => craftCallView(profile, spec.kind, (args ?? {}) ),
1038
+ presentResult: (args , result ) =>
1039
+ craftResultView(
1040
+ profile,
1041
+ spec.kind,
1042
+ (result.content ?? []).filter(block => block.type === 'text').map(block => block.text ?? '').join('\n'),
1043
+ result.isError === true,
1044
+ ),
864
1045
  })))
865
1046
  }
866
1047
  ctx.systemPrompt.section({
package/src/kit/types.js CHANGED
@@ -6,6 +6,10 @@
6
6
  * @module @dsh-artcraft/craft-kit/types
7
7
  */
8
8
 
9
+
10
+
11
+
12
+
9
13
  /** How one craft CLI spells its one-shot, headless "run some commands" verb. */
10
14
 
11
15