@cat-factory/executor-harness 1.104.0 → 1.108.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,235 @@
1
+ import { mkdir, stat, writeFile } from 'node:fs/promises'
2
+ import { join } from 'node:path'
3
+ import type { ImageFileSpec, ImageManifestSpec } from './job.js'
4
+ import { CONTEXT_DIR, excludeContextDir } from './pi.js'
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // The TRANSFER half of every image manifest: download the images the backend resolved for this job
8
+ // into a subdirectory of `.cat-context/`, and report what did not land.
9
+ //
10
+ // Shared by both manifests (the capture references and the design pictures) because the transfer is
11
+ // the same in every respect that matters here: the same download seam, the same per-image and
12
+ // whole-pass budgets, the same idempotence over a checkout an agent flow re-enters once per repair
13
+ // round, and the same rule that a miss is NAMED rather than silently absent. What differs is what
14
+ // the files mean, which is why each caller owns its own directory and its own prompt block.
15
+ //
16
+ // The harness MATERIALISES and never decides: which artifact belongs to which view, and what each
17
+ // file is called, are backend answers that ride the job body.
18
+ // ---------------------------------------------------------------------------
19
+
20
+ /** Per-image ceiling, matching the platform's own upload ceiling (16 MiB). */
21
+ const MAX_IMAGE_BYTES = 16 * 1024 * 1024
22
+
23
+ /** Per-image request timeout. */
24
+ const REQUEST_TIMEOUT_MS = 20_000
25
+
26
+ /**
27
+ * Wall-clock ceiling on the WHOLE pass.
28
+ *
29
+ * Downloading is activity-silent from the watchdog's point of view (no agent stream, no output),
30
+ * and `JOB_INACTIVITY_MS` (10 min) is what kills a job that stops producing. Rather than heartbeat
31
+ * a transfer that should take seconds, the pass is bounded far below that: a slow or wedged blob
32
+ * backend costs the run its images (stated to the agent) instead of costing it the run.
33
+ *
34
+ * Bounds ONE pass, and a job with both manifests runs two. That is deliberate: the alternative is a
35
+ * shared budget in which whichever manifest is delivered first can starve the other, which would
36
+ * make a capture's references depend on how many design pictures the same task happens to hold.
37
+ */
38
+ const TOTAL_BUDGET_MS = 90_000
39
+
40
+ /** How many images are fetched at once. Small on purpose: this is a shared blob backend. */
41
+ const CONCURRENCY = 4
42
+
43
+ /** What a transfer pass has on disk, and what it does not. */
44
+ export interface ContextImageOutcome {
45
+ written: { fileName: string; view: string }[]
46
+ /**
47
+ * One entry per image that is NOT on disk, with the cause stated in `reason`. Covers both halves
48
+ * of that absence, because the agent's position is the same either way (this view exists and
49
+ * there is no picture of it here): a transfer that failed, and a view the backend's own cap
50
+ * dropped before this container was ever asked to fetch it.
51
+ */
52
+ missing: { view: string; reason: string }[]
53
+ /** Where the written files live, relative to the checkout root. */
54
+ dir: string
55
+ }
56
+
57
+ /** The cause reported for a view the backend resolved but never sent this job a file for. */
58
+ const OMITTED_REASON = 'not sent to this container (image limit)'
59
+
60
+ /**
61
+ * Download a manifest's images into `<checkout>/.cat-context/<subdir>/` and report what landed.
62
+ *
63
+ * IDEMPOTENT, and that is load-bearing rather than an optimisation: an agent flow re-enters its
64
+ * workspace once per repair round, so this pass runs several times over one checkout. A file
65
+ * already on disk is counted and never re-fetched, which keeps a later round from spending the
66
+ * budget again AND from reporting an image as absent that pass 1 successfully delivered. A view
67
+ * that MISSED is retried, since the next round is a fresh chance at whatever was transiently down.
68
+ *
69
+ * Never throws: images are an aid, not a precondition for running, so a backend outage degrades the
70
+ * run to its textual context rather than failing it. Every miss is carried out on
71
+ * {@link ContextImageOutcome.missing} so the caller can say so in the prompt, which is the
72
+ * difference between an image the platform failed to hand over and a screen that does not exist.
73
+ */
74
+ export async function materializeContextImages(
75
+ cwd: string,
76
+ subdir: string,
77
+ spec: ImageManifestSpec,
78
+ options: { signal?: AbortSignal; fetchImpl?: typeof fetch } = {},
79
+ ): Promise<ContextImageOutcome> {
80
+ const dir = join(cwd, CONTEXT_DIR, subdir)
81
+ const outcome: ContextImageOutcome = {
82
+ written: [],
83
+ // The backend's own dropped views are missing before a single byte is fetched, and for a cause
84
+ // no transfer could have changed.
85
+ missing: spec.omitted.map((view) => ({ view, reason: OMITTED_REASON })),
86
+ dir: `${CONTEXT_DIR}/${subdir}`,
87
+ }
88
+ try {
89
+ await mkdir(dir, { recursive: true })
90
+ } catch (error) {
91
+ // Nowhere to write: report every image as missed rather than half of them, since none of them
92
+ // can land and the cause is the same for all.
93
+ for (const file of spec.files)
94
+ outcome.missing.push({ view: file.view, reason: describe(error) })
95
+ return sortByManifest(outcome, spec)
96
+ }
97
+ const deadline = Date.now() + TOTAL_BUDGET_MS
98
+ const queue = [...spec.files]
99
+ const workers = Array.from({ length: Math.min(CONCURRENCY, queue.length) }, async () => {
100
+ for (;;) {
101
+ const file = queue.shift()
102
+ if (!file) return
103
+ // An earlier pass over this same checkout already delivered it. Checked before the budget so
104
+ // a fully-delivered set costs one stat per file and no network at all, however long an
105
+ // earlier round took.
106
+ if (await alreadyOnDisk(dir, file.fileName)) {
107
+ outcome.written.push({ fileName: file.fileName, view: file.view })
108
+ continue
109
+ }
110
+ if (Date.now() >= deadline) {
111
+ outcome.missing.push({ view: file.view, reason: 'image download budget exhausted' })
112
+ continue
113
+ }
114
+ const failure = await downloadOne(dir, spec, file, options)
115
+ if (failure) outcome.missing.push({ view: file.view, reason: failure })
116
+ else outcome.written.push({ fileName: file.fileName, view: file.view })
117
+ }
118
+ })
119
+ await Promise.all(workers)
120
+ // Even a partial set must not reach the agent's PR (same rule as every other context file).
121
+ await excludeContextDir(cwd)
122
+ return sortByManifest(outcome, spec)
123
+ }
124
+
125
+ /**
126
+ * Order both lists the way the BACKEND composed the set (its own gallery order) rather than the
127
+ * order the transfers happened to finish in, so the list the agent reads is stable across rounds.
128
+ * The dropped views trail the sent ones, having no position in the manifest to sort by.
129
+ */
130
+ function sortByManifest(
131
+ outcome: ContextImageOutcome,
132
+ spec: ImageManifestSpec,
133
+ ): ContextImageOutcome {
134
+ const rank = new Map(spec.files.map((file, index) => [file.view, index] as const))
135
+ const at = (view: string) => rank.get(view) ?? Number.MAX_SAFE_INTEGER
136
+ outcome.written.sort((a, b) => at(a.view) - at(b.view))
137
+ outcome.missing.sort((a, b) => at(a.view) - at(b.view))
138
+ return outcome
139
+ }
140
+
141
+ /**
142
+ * Whether a previous pass over this checkout already wrote this image.
143
+ *
144
+ * Non-empty is the test, not mere existence: a zero-length file is what a half-written transfer
145
+ * leaves behind, and treating it as delivered would hand the agent a blank image it reads as a
146
+ * design with nothing on the screen (the same case {@link downloadOne} refuses to write).
147
+ */
148
+ async function alreadyOnDisk(dir: string, fileName: string): Promise<boolean> {
149
+ try {
150
+ return (await stat(join(dir, fileName))).size > 0
151
+ } catch {
152
+ // silent-catch-ok: absence is the ordinary answer here (first pass over the checkout), and any
153
+ // other stat failure is answered the same way — by attempting the download.
154
+ return false
155
+ }
156
+ }
157
+
158
+ /** Fetch and write one image, answering a failure reason or undefined on success. */
159
+ async function downloadOne(
160
+ dir: string,
161
+ spec: ImageManifestSpec,
162
+ file: ImageFileSpec,
163
+ options: { signal?: AbortSignal; fetchImpl?: typeof fetch },
164
+ ): Promise<string | undefined> {
165
+ const fetchImpl = options.fetchImpl ?? fetch
166
+ const timeout = AbortSignal.timeout(REQUEST_TIMEOUT_MS)
167
+ const signal = options.signal ? AbortSignal.any([options.signal, timeout]) : timeout
168
+ try {
169
+ const response = await fetchImpl(`${spec.url}/${encodeURIComponent(file.artifactId)}`, {
170
+ headers: { authorization: `Bearer ${spec.token}` },
171
+ signal,
172
+ })
173
+ if (!response.ok) return `HTTP ${response.status}`
174
+ const bytes = await readBounded(response, MAX_IMAGE_BYTES)
175
+ if (bytes === 'too-large') return 'image exceeds size limit'
176
+ // A zero-length body is a miss, not a file: written out it would be an image the agent opens,
177
+ // finds empty, and reads as a design with nothing on the screen.
178
+ if (!bytes.byteLength) return 'empty response'
179
+ await writeFile(join(dir, file.fileName), bytes)
180
+ return undefined
181
+ } catch (error) {
182
+ return describe(error)
183
+ }
184
+ }
185
+
186
+ /**
187
+ * Read a response body, refusing one that goes past `limit` WITHOUT buffering all of it first.
188
+ *
189
+ * The ceiling has to bound the transfer and not just the write. Buffering the whole body and then
190
+ * measuring it means an oversized (or endless) response is already resident, times the pass's
191
+ * concurrency, by the time it is rejected — which is the container's memory, in a run whose whole
192
+ * point is that it has not started working yet. So the declared length is refused up front where
193
+ * it is honest, and the stream is counted as it arrives and cancelled the moment it crosses the
194
+ * line, which is what makes a chunked or lying body cost no more than a truthful one.
195
+ */
196
+ async function readBounded(response: Response, limit: number): Promise<Uint8Array | 'too-large'> {
197
+ const declared = Number(response.headers.get('content-length'))
198
+ if (Number.isFinite(declared) && declared > limit) return 'too-large'
199
+ const body = response.body
200
+ if (!body) {
201
+ // No stream to count (a mocked or already-buffered response): fall back to measuring after the
202
+ // fact, which is sound because there is nothing left to stop arriving.
203
+ const bytes = new Uint8Array(await response.arrayBuffer())
204
+ return bytes.byteLength > limit ? 'too-large' : bytes
205
+ }
206
+ const reader = body.getReader()
207
+ const chunks: Uint8Array[] = []
208
+ let total = 0
209
+ try {
210
+ for (;;) {
211
+ const { done, value } = await reader.read()
212
+ if (done) break
213
+ total += value.byteLength
214
+ if (total > limit) {
215
+ await reader.cancel()
216
+ return 'too-large'
217
+ }
218
+ chunks.push(value)
219
+ }
220
+ } finally {
221
+ reader.releaseLock()
222
+ }
223
+ const bytes = new Uint8Array(total)
224
+ let offset = 0
225
+ for (const chunk of chunks) {
226
+ bytes.set(chunk, offset)
227
+ offset += chunk.byteLength
228
+ }
229
+ return bytes
230
+ }
231
+
232
+ /** A one-line cause for a failed transfer (never the token, which only rides a header). */
233
+ function describe(error: unknown): string {
234
+ return error instanceof Error ? error.message : String(error)
235
+ }
@@ -37,12 +37,12 @@ export interface ContextFileSpec {
37
37
  * container would let a harness image the deployment has not rolled out yet rename every view a
38
38
  * run reports, and the pairing would come apart with nothing failing.
39
39
  */
40
- export interface ReferenceScreenshotsSpec {
40
+ export interface ImageManifestSpec {
41
41
  /** Base URL of the reference download route; the artifact id is appended as a path segment. */
42
42
  url: string
43
43
  /** The run's container session token (the same one the LLM proxy is called with). */
44
44
  token: string
45
- files: ReferenceScreenshotSpec[]
45
+ files: ImageFileSpec[]
46
46
  /**
47
47
  * View names the task holds a reference for that this job was NOT sent a file for, because the
48
48
  * set was capped. Stated to the agent beside the transfers that failed: from where it stands
@@ -56,8 +56,8 @@ export interface ReferenceScreenshotsSpec {
56
56
  omitted: string[]
57
57
  }
58
58
 
59
- /** One reference image in a {@link ReferenceScreenshotsSpec}. `fileName` is sanitised on parse. */
60
- export interface ReferenceScreenshotSpec {
59
+ /** One reference image in a {@link ImageManifestSpec}. `fileName` is sanitised on parse. */
60
+ export interface ImageFileSpec {
61
61
  artifactId: string
62
62
  fileName: string
63
63
  view: string
@@ -99,34 +99,41 @@ export function parseContextFiles(value: unknown): ContextFileSpec[] {
99
99
  }
100
100
 
101
101
  /**
102
- * How many reference images one job may be handed. The backend caps the set it sends well below
103
- * this, so this is the harness's own backstop against a malformed or hostile body turning the
104
- * pre-run setup into an unbounded download, never the ceiling a real run meets.
102
+ * How many images one job may be handed PER MANIFEST. The backend caps each set it sends well
103
+ * below this, so this is the harness's own backstop against a malformed or hostile body turning
104
+ * the pre-run setup into an unbounded download, never the ceiling a real run meets.
105
+ *
106
+ * One number for both manifests on purpose: the two real ceilings are the BACKEND's, chosen where
107
+ * the reason for each is known (transfer time for a capture, input tokens for an attachment). A
108
+ * second backstop here would only encode those reasons a second time, in the one place that
109
+ * cannot see either.
105
110
  *
106
111
  * Hitting it is REPORTED rather than silently obeyed: an entry past the ceiling is dropped from
107
112
  * `files` and its view named on `omitted`, so an agent facing a truncated set is still told which
108
- * views to capture. A cap that shortened the list and said nothing would be indistinguishable, on
109
- * disk and in the prompt, from a design that simply has no such screen.
113
+ * views it is not being shown. A cap that shortened the list and said nothing would be
114
+ * indistinguishable, on disk and in the prompt, from a design that simply has no such screen.
110
115
  */
111
- const MAX_REFERENCE_SCREENSHOTS = 40
116
+ const MAX_MANIFEST_IMAGES = 40
112
117
 
113
118
  /**
114
- * Parse the reference-design manifest, or undefined when absent/unusable.
119
+ * Parse one image manifest (the capture set or the design pictures), or undefined when
120
+ * absent/unusable.
115
121
  *
116
- * The whole manifest is dropped when its transport half is unusable (no absolute http(s) URL, no
117
- * token): every file would fail the same way, and one stated cause beats N identical ones. An
118
- * individual entry is dropped only when it cannot name a file safely: the same basename
119
- * sanitisation every context file gets, so a hostile `fileName` can neither escape the directory
120
- * nor clobber a repo file.
122
+ * ONE parser for both, because the wire shape and every rule over it are the same: only the job
123
+ * body FIELD and what the harness does with the files afterwards differ. The whole manifest is
124
+ * dropped when its transport half is unusable (no absolute http(s) URL, no token): every file
125
+ * would fail the same way, and one stated cause beats N identical ones. An individual entry is
126
+ * dropped only when it cannot name a file safely: the same basename sanitisation every context
127
+ * file gets, so a hostile `fileName` can neither escape the directory nor clobber a repo file.
121
128
  */
122
- export function parseReferenceScreenshots(value: unknown): ReferenceScreenshotsSpec | undefined {
129
+ export function parseImageManifest(value: unknown): ImageManifestSpec | undefined {
123
130
  if (typeof value !== 'object' || value === null) return undefined
124
131
  const o = value as Record<string, unknown>
125
132
  const url = typeof o.url === 'string' ? o.url.trim() : ''
126
133
  const token = typeof o.token === 'string' ? o.token : ''
127
134
  if (!url || !token || !/^https?:\/\//i.test(url)) return undefined
128
135
  if (!Array.isArray(o.files)) return undefined
129
- const files: ReferenceScreenshotSpec[] = []
136
+ const files: ImageFileSpec[] = []
130
137
  // The backend's own dropped views come first; anything this parser drops joins them below.
131
138
  const omitted = Array.isArray(o.omitted)
132
139
  ? o.omitted.filter((view): view is string => typeof view === 'string' && view.length > 0)
@@ -144,7 +151,7 @@ export function parseReferenceScreenshots(value: unknown): ReferenceScreenshotsS
144
151
  // Past the backstop the entry is NAMED, not dropped: it stays a view the agent must capture.
145
152
  // Checked here rather than at the top of the loop so a malformed entry is refused on its own
146
153
  // terms (it names no usable view to report) instead of being counted against the ceiling.
147
- if (files.length >= MAX_REFERENCE_SCREENSHOTS) {
154
+ if (files.length >= MAX_MANIFEST_IMAGES) {
148
155
  omitted.push(view)
149
156
  continue
150
157
  }
@@ -0,0 +1,82 @@
1
+ import type { ImageManifestSpec } from './job.js'
2
+ import type { Logger } from './logger.js'
3
+ import { CONTEXT_DIR } from './pi.js'
4
+ import { type ContextImageOutcome, materializeContextImages } from './context-images.js'
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // DESIGN PICTURES on disk for a BUILDING kind: what the screen is supposed to look like, in
8
+ // `.cat-context/design-renders/`, for an agent CLI that can read an image into its turn.
9
+ //
10
+ // The other use of the same artifacts the capture path delivers, and the reason each has its own
11
+ // directory: a tester reading these six would take them for the complete list of views to capture,
12
+ // and a builder reading the tester's twenty-four would spend its context on screens it was never
13
+ // asked to touch.
14
+ //
15
+ // The agent is told about these files by the BACKEND's prompt, which is the half that knows which
16
+ // views the platform holds and whether this model can be shown them at all. What this module adds
17
+ // is the container's own half of the truth: which of those files actually landed here.
18
+ // ---------------------------------------------------------------------------
19
+
20
+ /** Subdirectory of {@link CONTEXT_DIR} the design pictures are written to. */
21
+ export const DESIGN_RENDER_SUBDIR = 'design-renders'
22
+
23
+ /** The relative directory the design pictures are written to (what the prompt names). */
24
+ export const DESIGN_RENDER_DIR = `${CONTEXT_DIR}/${DESIGN_RENDER_SUBDIR}`
25
+
26
+ /** Download this job's design pictures. See {@link materializeContextImages}. */
27
+ export function materializeDesignImages(
28
+ cwd: string,
29
+ spec: ImageManifestSpec,
30
+ options: { signal?: AbortSignal; fetchImpl?: typeof fetch } = {},
31
+ ): Promise<ContextImageOutcome> {
32
+ return materializeContextImages(cwd, DESIGN_RENDER_SUBDIR, spec, options)
33
+ }
34
+
35
+ /**
36
+ * Download the design pictures and answer the CORRECTION to the prompt's own list, or '' when
37
+ * everything the backend named is on disk.
38
+ *
39
+ * Silence on success is the difference from the capture delivery, and it is deliberate. The
40
+ * backend's prompt already names every picture and its view, because it is the side that knows
41
+ * which views exist and how they were delivered; repeating the list here would give the agent two
42
+ * lists of the same files, differing only when something went wrong, with nothing saying which one
43
+ * is current.
44
+ *
45
+ * So this speaks only when the container's truth DIVERGES from what the prompt promised. That
46
+ * divergence has to be stated: an agent told to open a file that is not there re-reads the path,
47
+ * lists the directory and eventually decides the design is missing something, when the honest
48
+ * answer is that this one picture did not transfer and the rest are exactly as described.
49
+ */
50
+ export async function deliverDesignImages(
51
+ cwd: string,
52
+ spec: ImageManifestSpec | undefined,
53
+ options: { signal?: AbortSignal; log: Logger; fetchImpl?: typeof fetch },
54
+ ): Promise<string> {
55
+ if (!spec) return ''
56
+ const outcome = await materializeDesignImages(cwd, spec, options)
57
+ if (outcome.missing.length) {
58
+ options.log.warn('agent: some design pictures are not on disk', {
59
+ written: outcome.written.length,
60
+ missing: outcome.missing.length,
61
+ reasons: outcome.missing.map((file) => file.reason).slice(0, 5),
62
+ })
63
+ }
64
+ return designImageGuidance(outcome)
65
+ }
66
+
67
+ /**
68
+ * The prompt correction: the views whose picture is NOT in this container, with the cause.
69
+ *
70
+ * Empty whenever the transfer matched the prompt, including the case where the manifest was empty
71
+ * to begin with. The agent is told to carry on from the textual design description rather than to
72
+ * ask for the file, because nothing in the run can deliver it after this point.
73
+ */
74
+ export function designImageGuidance(outcome: ContextImageOutcome): string {
75
+ if (!outcome.missing.length) return ''
76
+ return `
77
+
78
+ ## Design pictures: correction
79
+ These views were listed above as pictures, and are NOT in this container. Work from the textual
80
+ design description for them; there is nothing to open and nothing that can fetch them now:
81
+ ${outcome.missing.map((file) => `- ${file.view}: NOT on disk (${file.reason})`).join('\n')}`
82
+ }
@@ -0,0 +1,43 @@
1
+ import type { ImageManifestSpec } from './job.js'
2
+ import type { Logger } from './logger.js'
3
+ import { deliverDesignImages } from './design-images.js'
4
+ import { deliverReferenceScreenshots } from './reference-screenshots.js'
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // Both image deliveries of one job, in one call.
8
+ //
9
+ // A job can legitimately carry both manifests (a capturing kind that also builds), and every
10
+ // agent-running flow has to perform both or neither: a flow that did half would leave the agent
11
+ // with a directory it was told about and never got, which is invisible until the output degrades.
12
+ // One entry point makes that impossible to get half-right, the same reason `agentCapabilities`
13
+ // exists for the fields these come from.
14
+ // ---------------------------------------------------------------------------
15
+
16
+ /** The manifests a job may carry, as they sit on any agent-running spec. */
17
+ export interface JobImageSpecs {
18
+ referenceScreenshots?: ImageManifestSpec
19
+ designImages?: ImageManifestSpec
20
+ }
21
+
22
+ /**
23
+ * Deliver both manifests into the checkout and answer the prompt text they contribute.
24
+ *
25
+ * The two blocks are CONCATENATED rather than kept apart because they say the same kind of thing
26
+ * (what this container actually holds, against what the run was told it would), and the agent reads
27
+ * one context. Either half is empty when its manifest is absent or when nothing needs saying, so a
28
+ * job with one manifest is byte-identical to what it produced before the other existed.
29
+ *
30
+ * Runs once per PASS, not once per job: a coding flow re-enters its workspace for every repair
31
+ * round. That is safe because each delivery is idempotent over the checkout (a file already on disk
32
+ * is counted, never re-fetched), so a later round costs a stat per image and cannot report a view an
33
+ * earlier round successfully delivered as absent. A view that MISSED is retried, which is the
34
+ * behaviour worth having: the next round is a fresh chance at a blob backend that was briefly down.
35
+ */
36
+ export async function deliverJobImages(
37
+ spec: JobImageSpecs & { dir: string },
38
+ options: { signal?: AbortSignal; log: Logger; fetchImpl?: typeof fetch },
39
+ ): Promise<string> {
40
+ const references = await deliverReferenceScreenshots(spec.dir, spec.referenceScreenshots, options)
41
+ const designs = await deliverDesignImages(spec.dir, spec.designImages, options)
42
+ return `${references}${designs}`
43
+ }
package/src/job.ts CHANGED
@@ -24,10 +24,10 @@ import {
24
24
  import { type TestSecretSpec, parseInfraEnv, parseSecretEnvPairs, str } from './job-env.js'
25
25
  import {
26
26
  parseContextFiles,
27
- parseReferenceScreenshots,
27
+ parseImageManifest,
28
28
  type ContextFileSpec,
29
- type ReferenceScreenshotSpec,
30
- type ReferenceScreenshotsSpec,
29
+ type ImageFileSpec,
30
+ type ImageManifestSpec,
31
31
  } from './context-manifests.js'
32
32
 
33
33
  // Re-exported so a handler describing a job keeps ONE import site (the env-pair shape is a job
@@ -39,7 +39,7 @@ export type { McpServerSpec, SkillResourceSpec, SkillSpec }
39
39
 
40
40
  // Same rule for the two staged-file manifests: their shapes and their defensive parsing moved to
41
41
  // `context-manifests.ts`, but they remain job body fields, so this stays the import site.
42
- export type { ContextFileSpec, ReferenceScreenshotSpec, ReferenceScreenshotsSpec }
42
+ export type { ContextFileSpec, ImageFileSpec, ImageManifestSpec }
43
43
 
44
44
  // The job the Worker's ContainerAgentExecutor POSTs to /run. Kept as plain
45
45
  // types with a hand-rolled validator so the image needs no schema dependency.
@@ -712,11 +712,23 @@ export interface AgentJob extends HarnessAuthFields {
712
712
  contextFiles?: ContextFileSpec[]
713
713
  /**
714
714
  * The task's reference design images, downloaded into `.cat-context/reference-screenshots/`
715
- * before the agent runs (see {@link ReferenceScreenshotsSpec}). Sent only for a kind that
715
+ * before the agent runs (see {@link ImageManifestSpec}). Sent only for a kind that
716
716
  * CAPTURES views and only when the task actually has references, so absent is the normal case
717
717
  * and means the agent names its own views.
718
718
  */
719
- referenceScreenshots?: ReferenceScreenshotsSpec
719
+ referenceScreenshots?: ImageManifestSpec
720
+ /**
721
+ * The PICTURES of the task's designs, for a kind that builds or plans a screen. Downloaded into
722
+ * `.cat-context/design-renders/` before the run; the agent's prompt (composed by the backend)
723
+ * names each file and its view, and the agent opens them with its own image-reading tool.
724
+ *
725
+ * The same wire shape and the same download seam as {@link AgentJob.referenceScreenshots}, and a
726
+ * separate field with a separate directory because the two are opposite instructions: that one
727
+ * names the views to CAPTURE, this one is the design to BUILD. Sent only when the backend
728
+ * decided this harness can read an image at all, so absent is the normal case and means the run
729
+ * works from the textual design description (its prompt says which).
730
+ */
731
+ designImages?: ImageManifestSpec
720
732
  /**
721
733
  * Private package-registry auth (npm private orgs, GitHub Packages), rendered into
722
734
  * `~/.npmrc` before the run so the checkout's installs — the agent's own and the
@@ -1200,7 +1212,8 @@ export function parseAgentJob(input: unknown): AgentJob {
1200
1212
  referenceBranches: parseReferenceBranches(o.referenceBranches),
1201
1213
  bootstrap: parseAgentBootstrapSpec(o.bootstrap),
1202
1214
  contextFiles: parseContextFiles(o.contextFiles),
1203
- referenceScreenshots: parseReferenceScreenshots(o.referenceScreenshots),
1215
+ referenceScreenshots: parseImageManifest(o.referenceScreenshots),
1216
+ designImages: parseImageManifest(o.designImages),
1204
1217
  packageRegistries: parsePackageRegistries(o.packageRegistries),
1205
1218
  skills: parseSkillSpecs(o.skills),
1206
1219
  mcpServers: parseMcpServerSpecs(o.mcpServers),
@@ -1243,7 +1256,8 @@ interface ParsedAgentJobParts {
1243
1256
  referenceBranches: ReturnType<typeof parseReferenceBranches>
1244
1257
  bootstrap: ReturnType<typeof parseAgentBootstrapSpec>
1245
1258
  contextFiles: ReturnType<typeof parseContextFiles>
1246
- referenceScreenshots: ReturnType<typeof parseReferenceScreenshots>
1259
+ referenceScreenshots: ReturnType<typeof parseImageManifest>
1260
+ designImages: ReturnType<typeof parseImageManifest>
1247
1261
  packageRegistries: ReturnType<typeof parsePackageRegistries>
1248
1262
  skills: ReturnType<typeof parseSkillSpecs>
1249
1263
  mcpServers: ReturnType<typeof parseMcpServerSpecs>
@@ -1302,6 +1316,7 @@ function assembleAgentJob(
1302
1316
  bootstrap,
1303
1317
  contextFiles,
1304
1318
  referenceScreenshots,
1319
+ designImages,
1305
1320
  packageRegistries,
1306
1321
  skills,
1307
1322
  mcpServers,
@@ -1330,6 +1345,7 @@ function assembleAgentJob(
1330
1345
  ...(output ? { output } : {}),
1331
1346
  ...(contextFiles.length ? { contextFiles } : {}),
1332
1347
  ...(referenceScreenshots ? { referenceScreenshots } : {}),
1348
+ ...(designImages ? { designImages } : {}),
1333
1349
  ...(packageRegistries.length ? { packageRegistries } : {}),
1334
1350
  ...(skills ? { skills } : {}),
1335
1351
  ...(mcpServers ? { mcpServers } : {}),
@@ -1,8 +1,8 @@
1
1
  import { mkdir, mkdtemp, readdir, rm, stat } from 'node:fs/promises'
2
2
  import { tmpdir } from 'node:os'
3
3
  import { join } from 'node:path'
4
- import type { RepoSpec, ReferenceScreenshotsSpec } from './job.js'
5
- import { deliverReferenceScreenshots } from './reference-screenshots.js'
4
+ import type { RepoSpec, ImageManifestSpec } from './job.js'
5
+ import { deliverJobImages } from './job-images.js'
6
6
  import type { McpServerSpec, SkillSpec } from './agent-capabilities.js'
7
7
  import { readEffortReport } from './effort.js'
8
8
  import { log } from './logger.js'
@@ -218,7 +218,14 @@ export interface AgentRunSpec {
218
218
  * before the run and named in the agent's prompt, so a capturing agent can compare against them
219
219
  * and use their view names. Absent ⇒ nothing is downloaded and nothing is said.
220
220
  */
221
- referenceScreenshots?: ReferenceScreenshotsSpec
221
+ referenceScreenshots?: ImageManifestSpec
222
+ /**
223
+ * The PICTURES of the task's designs. Downloaded into `.cat-context/design-renders/` before the
224
+ * run; the agent's prompt (composed by the backend) already names each file and its view, so the
225
+ * only thing said here is a CORRECTION when one of them did not land. Absent ⇒ nothing is
226
+ * downloaded and nothing is said.
227
+ */
228
+ designImages?: ImageManifestSpec
222
229
  /**
223
230
  * The skills to make available for this run — a `skill` step's picked skill and/or the playbooks
224
231
  * the running agent kind declares. Installed HARNESS-AWARE: the claude-code runner writes them
@@ -301,7 +308,7 @@ export async function runAgentInWorkspace(
301
308
  // cannot report a view an earlier round successfully delivered as absent. A view that MISSED is
302
309
  // retried, which is the behaviour worth having: the next round is a fresh chance at a blob
303
310
  // backend that was briefly down.
304
- const referenceGuidance = await deliverReferenceScreenshots(spec.dir, spec.referenceScreenshots, {
311
+ const imageGuidance = await deliverJobImages(spec, {
305
312
  ...(opts.signal ? { signal: opts.signal } : {}),
306
313
  log: opts.log ?? log,
307
314
  })
@@ -328,7 +335,7 @@ export async function runAgentInWorkspace(
328
335
  const subOutcome = await runSubscriptionHarness(spec.harness, {
329
336
  cwd: spec.dir,
330
337
  model: spec.model,
331
- systemPrompt: `${subscriptionSystemPrompt(spec.systemPrompt, contextFiles)}${referenceGuidance}`,
338
+ systemPrompt: `${subscriptionSystemPrompt(spec.systemPrompt, contextFiles)}${imageGuidance}`,
332
339
  userPrompt: spec.userPrompt,
333
340
  ...(spec.subscriptionToken ? { subscriptionToken: spec.subscriptionToken } : {}),
334
341
  subscriptionBaseUrl: spec.subscriptionBaseUrl,
@@ -398,7 +405,7 @@ export async function runAgentInWorkspace(
398
405
  serviceDirectory: spec.serviceDirectory,
399
406
  contextFiles,
400
407
  hasBlueprints,
401
- ...(referenceGuidance ? { referenceGuidance } : {}),
408
+ ...(imageGuidance ? { referenceGuidance: imageGuidance } : {}),
402
409
  ...(spec.multiRepo ? { multiRepo: true } : {}),
403
410
  })
404
411
  // Pi's calls are metered server-side by the LLM proxy, which sees only an HTTP request — so