dsh-clone 0.0.0-stage → 0.2.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.
- package/ACCEPTABLE-USE.md +28 -0
- package/CHANGELOG.md +96 -0
- package/LICENSE +21 -0
- package/README.md +260 -2
- package/client.js +690 -0
- package/cordis.patch.yml +9 -0
- package/docs/assets/icon-180.png +0 -0
- package/icon.svg +29 -0
- package/index.js +626 -0
- package/index.json +75 -0
- package/lib/engine.js +999 -0
- package/lib/parse.js +58 -0
- package/lib/png.js +175 -0
- package/lib/preview.js +141 -0
- package/lib/prompts.js +118 -0
- package/lib/rank.js +72 -0
- package/lib/render.js +148 -0
- package/lib/score-lane.js +38 -0
- package/lib/score.js +282 -0
- package/lib/util.js +85 -0
- package/locale/en.json +6 -0
- package/locale/ru.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +73 -4
- package/screenshots.json +6 -0
package/lib/engine.js
ADDED
|
@@ -0,0 +1,999 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The clone engine: one reference screenshot, N isolated build lanes, one
|
|
3
|
+
* adoptable result.
|
|
4
|
+
*
|
|
5
|
+
* The engine owns no Harness types and no Node imports beyond paths — every
|
|
6
|
+
* side effect (filesystem, git, subagents, sessions) arrives as a dependency.
|
|
7
|
+
* That keeps the whole state machine testable in plain Node and keeps the
|
|
8
|
+
* Cordis wiring in index.js thin.
|
|
9
|
+
*
|
|
10
|
+
* Lifecycle of one clone:
|
|
11
|
+
*
|
|
12
|
+
* start() reference copied, lanes prepared, children launched
|
|
13
|
+
* prepareLane() plain directory, or a git worktree inside the repo
|
|
14
|
+
* launchLane() one subagent per lane, each in its own directory
|
|
15
|
+
* tick() live counters (tools, tokens) for the panel
|
|
16
|
+
* onLaneSettled() files collected, lane becomes adoptable
|
|
17
|
+
* finish() status finished, suggested winner chosen
|
|
18
|
+
* pick(lane) chosen lane copied into the workspace
|
|
19
|
+
* cancel() running children interrupted, lanes marked cancelled
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { basename, join } from 'node:path'
|
|
23
|
+
import { LANE_STRATEGIES, buildLanePrompt, buildRefinePrompt, strategyForLane } from './prompts.js'
|
|
24
|
+
import { suggestedWinner, summarize } from './rank.js'
|
|
25
|
+
import { readPngSize } from './png.js'
|
|
26
|
+
import { reasonOf, totalTokens, truncate } from './util.js'
|
|
27
|
+
|
|
28
|
+
/** Names never copied out of a lane, and never walked for the file inventory. */
|
|
29
|
+
const IGNORED_NAMES = new Set(['node_modules', '.dsh-clone', '.DS_Store'])
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Whether a lane entry is scratch rather than deliverable output.
|
|
33
|
+
* A builder may keep working files (crops of the reference, notes) in a dotted
|
|
34
|
+
* directory; none of that belongs in the inventory or in the adopted copy.
|
|
35
|
+
* @param name - one directory entry name.
|
|
36
|
+
* @returns true when the entry must be skipped.
|
|
37
|
+
*/
|
|
38
|
+
export function isScratchEntry(name) {
|
|
39
|
+
if (typeof name !== 'string' || name.length === 0) return true
|
|
40
|
+
if (IGNORED_NAMES.has(name)) return true
|
|
41
|
+
return name.startsWith('.')
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Dependency directories reused from the workspace instead of reinstalled. */
|
|
45
|
+
const SHARED_DIRECTORIES = ['node_modules', '.venv', 'vendor']
|
|
46
|
+
|
|
47
|
+
/** Capacities that bound one clone's disk and event-loop cost. */
|
|
48
|
+
const LIMITS = {
|
|
49
|
+
filesPerLane: 400,
|
|
50
|
+
copiedBytes: 64 * 1024 * 1024,
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Version of the on-disk clone record. Bump when the shape changes so an older
|
|
55
|
+
* file is ignored instead of half-understood.
|
|
56
|
+
*/
|
|
57
|
+
const STATE_VERSION = 1
|
|
58
|
+
|
|
59
|
+
export class CloneEngine {
|
|
60
|
+
/**
|
|
61
|
+
* @param deps - injected side effects (see the module doc).
|
|
62
|
+
*/
|
|
63
|
+
constructor(deps) {
|
|
64
|
+
this.deps = deps
|
|
65
|
+
this.clones = new Map()
|
|
66
|
+
this.order = []
|
|
67
|
+
this.laneTimers = new Map()
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Arm the wall-clock limit for one lane. */
|
|
71
|
+
armLaneTimer(clone, lane) {
|
|
72
|
+
const limit = Number(this.deps.config?.laneTimeoutMs)
|
|
73
|
+
if (!Number.isFinite(limit) || limit <= 0) return
|
|
74
|
+
const timer = setTimeout(() => {
|
|
75
|
+
Promise.resolve(this.forceSettle(clone, lane)).catch((error) => {
|
|
76
|
+
this.deps.logger?.warn?.(`dsh-clone: lane ${lane.index + 1} time-limit handling failed: ${reasonOf(error)}`)
|
|
77
|
+
})
|
|
78
|
+
}, limit)
|
|
79
|
+
if (typeof timer?.unref === 'function') timer.unref()
|
|
80
|
+
this.laneTimers.set(`${clone.id}:${lane.index}`, timer)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Disarm one lane's wall-clock limit. */
|
|
84
|
+
clearLaneTimer(clone, lane) {
|
|
85
|
+
const key = `${clone.id}:${lane.index}`
|
|
86
|
+
const timer = this.laneTimers.get(key)
|
|
87
|
+
if (timer !== undefined) {
|
|
88
|
+
clearTimeout(timer)
|
|
89
|
+
this.laneTimers.delete(key)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Stop waiting for a lane that exceeded its wall-clock limit.
|
|
95
|
+
*
|
|
96
|
+
* A builder that keeps polishing past its budget is not a failure: if it
|
|
97
|
+
* already wrote an `index.html`, that page is delivered and scored exactly as
|
|
98
|
+
* on a normal finish. Only a lane with nothing usable becomes `empty`.
|
|
99
|
+
* @param clone - owning clone.
|
|
100
|
+
* @param lane - the lane to settle.
|
|
101
|
+
*/
|
|
102
|
+
async forceSettle(clone, lane, reason = 'time-limit') {
|
|
103
|
+
if (lane.status !== 'running') return
|
|
104
|
+
this.clearLaneTimer(clone, lane)
|
|
105
|
+
lane.timedOut = true
|
|
106
|
+
lane.finishedAt = this.deps.now()
|
|
107
|
+
lane.stopReason = reason
|
|
108
|
+
try {
|
|
109
|
+
this.deps.interruptAgent({ childId: lane.childId, parent: clone.parent })
|
|
110
|
+
} catch (error) {
|
|
111
|
+
lane.error = reasonOf(error)
|
|
112
|
+
}
|
|
113
|
+
lane.files = await this.inventory(lane.dir).catch(() => [])
|
|
114
|
+
const delivered = lane.files.some((file) => file.path === 'index.html')
|
|
115
|
+
if (!delivered) {
|
|
116
|
+
lane.status = 'empty'
|
|
117
|
+
lane.error = 'the lane hit its time limit before writing index.html'
|
|
118
|
+
} else {
|
|
119
|
+
lane.status = 'scoring'
|
|
120
|
+
await this.scoreLane(clone, lane)
|
|
121
|
+
lane.status = 'settled'
|
|
122
|
+
}
|
|
123
|
+
this.finishIfDone(clone)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Whether any clone still has work in flight (drives the poll timer). */
|
|
127
|
+
hasRunning() {
|
|
128
|
+
for (const clone of this.clones.values()) {
|
|
129
|
+
if (clone.status === 'running') return true
|
|
130
|
+
}
|
|
131
|
+
return false
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** One clone by id. */
|
|
135
|
+
get(cloneId) {
|
|
136
|
+
return this.clones.get(cloneId)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Most recent clone for a session, or null. */
|
|
140
|
+
latestForSession(sessionId) {
|
|
141
|
+
for (const id of this.order) {
|
|
142
|
+
const clone = this.clones.get(id)
|
|
143
|
+
if (clone !== undefined && (sessionId === undefined || clone.sessionId === sessionId)) return clone
|
|
144
|
+
}
|
|
145
|
+
return null
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Every clone belonging to one session, newest first. */
|
|
149
|
+
listForSession(sessionId) {
|
|
150
|
+
return this.order
|
|
151
|
+
.map((id) => this.clones.get(id))
|
|
152
|
+
.filter((clone) => clone !== undefined && (sessionId === undefined || clone.sessionId === sessionId))
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Persist one clone's state next to its work.
|
|
157
|
+
*
|
|
158
|
+
* The engine's memory dies with the plugin fiber, but a finished clone and its
|
|
159
|
+
* files do not: writing a small record means a reload or a restart does not
|
|
160
|
+
* throw away an expensive run. Failures are logged and ignored — persistence
|
|
161
|
+
* is a convenience, never a precondition.
|
|
162
|
+
* @param clone - the clone to record.
|
|
163
|
+
*/
|
|
164
|
+
async saveState(clone) {
|
|
165
|
+
const record = {
|
|
166
|
+
version: STATE_VERSION,
|
|
167
|
+
id: clone.id,
|
|
168
|
+
sessionId: clone.sessionId,
|
|
169
|
+
workspace: clone.workspace,
|
|
170
|
+
note: clone.note,
|
|
171
|
+
viewport: clone.viewport,
|
|
172
|
+
status: clone.status,
|
|
173
|
+
createdAt: clone.createdAt,
|
|
174
|
+
finishedAt: clone.finishedAt,
|
|
175
|
+
reference: clone.reference,
|
|
176
|
+
repo: clone.repo,
|
|
177
|
+
winner: clone.winner,
|
|
178
|
+
picked: clone.picked,
|
|
179
|
+
kind: clone.kind ?? 'clone',
|
|
180
|
+
sourceId: clone.sourceId ?? null,
|
|
181
|
+
sourceScore: typeof clone.sourceScore === 'number' ? clone.sourceScore : null,
|
|
182
|
+
delta: typeof clone.delta === 'number' ? clone.delta : null,
|
|
183
|
+
lanes: clone.lanes.map((lane) => ({
|
|
184
|
+
index: lane.index,
|
|
185
|
+
strategy: lane.strategy?.id ?? 'measure-first',
|
|
186
|
+
status: lane.status,
|
|
187
|
+
dir: lane.dir,
|
|
188
|
+
branch: lane.branch ?? null,
|
|
189
|
+
startedAt: lane.startedAt,
|
|
190
|
+
finishedAt: lane.finishedAt,
|
|
191
|
+
tools: lane.tools,
|
|
192
|
+
tokens: lane.tokens,
|
|
193
|
+
score: typeof lane.score === 'number' ? lane.score : null,
|
|
194
|
+
renderPath: lane.renderPath ?? null,
|
|
195
|
+
files: lane.files,
|
|
196
|
+
stopReason: lane.stopReason ?? null,
|
|
197
|
+
timedOut: lane.timedOut === true,
|
|
198
|
+
nudged: lane.nudged === true,
|
|
199
|
+
error: lane.error ?? null,
|
|
200
|
+
report: lane.report ?? null,
|
|
201
|
+
})),
|
|
202
|
+
}
|
|
203
|
+
try {
|
|
204
|
+
await this.deps.fs.writeFile(join(clone.root, 'state.json'), JSON.stringify(record, null, 2))
|
|
205
|
+
} catch (error) {
|
|
206
|
+
this.deps.logger?.warn?.(`dsh-clone: could not record clone ${clone.id}: ${reasonOf(error)}`)
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Rebuild the in-memory clones for one workspace from their state files.
|
|
212
|
+
*
|
|
213
|
+
* Anything that was still running when the plugin went away cannot be resumed:
|
|
214
|
+
* its child agent is gone. Such a lane keeps whatever it already wrote (so the
|
|
215
|
+
* work is adoptable) and is marked interrupted, which is the honest description
|
|
216
|
+
* of what happened.
|
|
217
|
+
* @param workspace - absolute workspace directory to scan.
|
|
218
|
+
* @returns the number of clones restored.
|
|
219
|
+
*/
|
|
220
|
+
async restore(workspace) {
|
|
221
|
+
const deps = this.deps
|
|
222
|
+
let entries
|
|
223
|
+
try {
|
|
224
|
+
entries = await deps.fs.readdir(join(workspace, '.dsh-clone'), { withFileTypes: true })
|
|
225
|
+
} catch {
|
|
226
|
+
return 0
|
|
227
|
+
}
|
|
228
|
+
let restored = 0
|
|
229
|
+
for (const entry of entries) {
|
|
230
|
+
if (!entry.isDirectory() || this.clones.has(entry.name)) continue
|
|
231
|
+
const root = join(workspace, '.dsh-clone', entry.name)
|
|
232
|
+
let record
|
|
233
|
+
try {
|
|
234
|
+
record = JSON.parse(await deps.fs.readFile(join(root, 'state.json'), 'utf8'))
|
|
235
|
+
} catch {
|
|
236
|
+
continue
|
|
237
|
+
}
|
|
238
|
+
if (record?.version !== STATE_VERSION || typeof record.id !== 'string') continue
|
|
239
|
+
if (record.workspace !== undefined && record.workspace !== workspace) continue
|
|
240
|
+
|
|
241
|
+
const lanes = Array.isArray(record.lanes) ? record.lanes.map((lane) => {
|
|
242
|
+
const files = Array.isArray(lane.files) ? lane.files : []
|
|
243
|
+
const delivered = files.some((file) => file?.path === 'index.html')
|
|
244
|
+
const unfinished = ['running', 'scoring', 'queued'].includes(lane.status)
|
|
245
|
+
return {
|
|
246
|
+
index: lane.index,
|
|
247
|
+
strategy: LANE_STRATEGIES.find((candidate) => candidate.id === lane.strategy) ?? LANE_STRATEGIES[0],
|
|
248
|
+
status: unfinished ? (delivered ? 'settled' : 'interrupted') : lane.status,
|
|
249
|
+
dir: lane.dir,
|
|
250
|
+
branch: lane.branch ?? undefined,
|
|
251
|
+
childId: null,
|
|
252
|
+
startedAt: lane.startedAt ?? null,
|
|
253
|
+
finishedAt: lane.finishedAt ?? null,
|
|
254
|
+
tools: lane.tools ?? 0,
|
|
255
|
+
tokens: lane.tokens ?? { input: 0, output: 0, cacheRead: 0 },
|
|
256
|
+
files,
|
|
257
|
+
stopReason: unfinished ? 'interrupted' : lane.stopReason ?? null,
|
|
258
|
+
error: unfinished ? 'the plugin restarted while this lane was building' : lane.error ?? null,
|
|
259
|
+
report: lane.report ?? null,
|
|
260
|
+
timedOut: unfinished ? true : lane.timedOut === true,
|
|
261
|
+
score: typeof lane.score === 'number' ? lane.score : undefined,
|
|
262
|
+
renderPath: lane.renderPath ?? undefined,
|
|
263
|
+
previewUrl: `/api/dsh-clone/preview/${record.id}/${lane.index + 1}/`,
|
|
264
|
+
}
|
|
265
|
+
}) : []
|
|
266
|
+
|
|
267
|
+
const terminal = lanes.every((lane) => ['settled', 'empty', 'failed', 'cancelled', 'interrupted'].includes(lane.status))
|
|
268
|
+
const clone = {
|
|
269
|
+
id: record.id,
|
|
270
|
+
sessionId: record.sessionId,
|
|
271
|
+
workspace,
|
|
272
|
+
root,
|
|
273
|
+
repo: record.repo ?? null,
|
|
274
|
+
note: typeof record.note === 'string' ? record.note : '',
|
|
275
|
+
viewport: typeof record.viewport === 'string' ? record.viewport : '1280x800',
|
|
276
|
+
status: record.status === 'discarded' ? 'discarded' : terminal ? 'finished' : 'interrupted',
|
|
277
|
+
starting: false,
|
|
278
|
+
createdAt: record.createdAt ?? deps.now(),
|
|
279
|
+
finishedAt: record.finishedAt ?? null,
|
|
280
|
+
reference: record.reference ?? { path: '', mediaType: 'image/png', label: 'reference' },
|
|
281
|
+
lanes,
|
|
282
|
+
winner: record.winner ?? null,
|
|
283
|
+
picked: record.picked ?? null,
|
|
284
|
+
error: null,
|
|
285
|
+
parent: undefined,
|
|
286
|
+
kind: record.kind === 'refine' ? 'refine' : 'clone',
|
|
287
|
+
sourceId: record.sourceId ?? null,
|
|
288
|
+
sourceScore: typeof record.sourceScore === 'number' ? record.sourceScore : null,
|
|
289
|
+
delta: typeof record.delta === 'number' ? record.delta : null,
|
|
290
|
+
restored: true,
|
|
291
|
+
}
|
|
292
|
+
if (clone.winner === null && clone.status === 'finished') {
|
|
293
|
+
const winner = suggestedWinner(lanes)
|
|
294
|
+
clone.winner = winner === null ? null : winner.index
|
|
295
|
+
}
|
|
296
|
+
this.clones.set(clone.id, clone)
|
|
297
|
+
this.order.push(clone.id)
|
|
298
|
+
restored += 1
|
|
299
|
+
}
|
|
300
|
+
this.order.sort((left, right) => {
|
|
301
|
+
const a = this.clones.get(left)?.createdAt ?? 0
|
|
302
|
+
const b = this.clones.get(right)?.createdAt ?? 0
|
|
303
|
+
return b - a
|
|
304
|
+
})
|
|
305
|
+
if (restored > 0) deps.logger?.warn?.(`dsh-clone: restored ${restored} clone(s) from this workspace`)
|
|
306
|
+
return restored
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Stop everything this engine is driving.
|
|
311
|
+
*
|
|
312
|
+
* Called when the plugin unloads. Leaving child agents running would keep
|
|
313
|
+
* spending tokens with nobody reading the result, and their clone would be
|
|
314
|
+
* unreachable.
|
|
315
|
+
*/
|
|
316
|
+
dispose() {
|
|
317
|
+
for (const clone of this.clones.values()) {
|
|
318
|
+
for (const lane of clone.lanes) {
|
|
319
|
+
this.clearLaneTimer(clone, lane)
|
|
320
|
+
if (lane.status !== 'running' && lane.status !== 'scoring') continue
|
|
321
|
+
lane.status = lane.status === 'scoring' ? 'settled' : 'cancelled'
|
|
322
|
+
lane.finishedAt = this.deps.now()
|
|
323
|
+
lane.stopReason = 'plugin-unload'
|
|
324
|
+
try {
|
|
325
|
+
this.deps.interruptAgent({ childId: lane.childId, parent: clone.parent })
|
|
326
|
+
} catch {
|
|
327
|
+
/* the child may already be gone */
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
if (clone.status === 'running') {
|
|
331
|
+
clone.status = 'finished'
|
|
332
|
+
clone.finishedAt = this.deps.now()
|
|
333
|
+
clone.starting = false
|
|
334
|
+
}
|
|
335
|
+
Promise.resolve(this.saveState(clone)).catch(() => {})
|
|
336
|
+
}
|
|
337
|
+
this.laneTimers.clear()
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Start one clone.
|
|
342
|
+
* @param args - calling agent, session id, lane count and the user's note.
|
|
343
|
+
* @returns the live clone record.
|
|
344
|
+
*/
|
|
345
|
+
async start({ agent, sessionId, lanes, note, viewport }) {
|
|
346
|
+
const deps = this.deps
|
|
347
|
+
const given = await deps.workingDirectory.ensure(agent)
|
|
348
|
+
// Canonicalize the workspace before it becomes a path prefix: `git
|
|
349
|
+
// rev-parse --show-toplevel` answers with resolved paths, so an
|
|
350
|
+
// uncanonicalized session directory (a `/var` → `/private/var` symlink on
|
|
351
|
+
// macOS, for instance) would produce two spellings of the same tree.
|
|
352
|
+
const workspace = typeof deps.fs.realpath === 'function'
|
|
353
|
+
? await deps.fs.realpath(given).catch(() => given)
|
|
354
|
+
: given
|
|
355
|
+
const reference = await deps.resolveReference({ agent, note })
|
|
356
|
+
if (reference === null || reference === undefined) {
|
|
357
|
+
throw new Error('dsh-clone: no reference screenshot. Paste an image into the conversation, or pass a path: /clone 3 ./mock.png')
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const repo = await this.inspectRepo(workspace)
|
|
361
|
+
const id = deps.id().replace(/[^a-z0-9]/giu, '').slice(0, 8)
|
|
362
|
+
const root = join(workspace, '.dsh-clone', id)
|
|
363
|
+
await deps.fs.mkdir(root, { recursive: true })
|
|
364
|
+
|
|
365
|
+
const referencePath = join(root, `reference${reference.extension ?? '.png'}`)
|
|
366
|
+
await deps.fs.copyFile(reference.path, referencePath)
|
|
367
|
+
|
|
368
|
+
const clone = {
|
|
369
|
+
id,
|
|
370
|
+
sessionId,
|
|
371
|
+
workspace,
|
|
372
|
+
root,
|
|
373
|
+
repo,
|
|
374
|
+
note: truncate(String(note ?? ''), 300),
|
|
375
|
+
viewport: await this.resolveViewport(viewport, referencePath),
|
|
376
|
+
status: 'running',
|
|
377
|
+
starting: true,
|
|
378
|
+
createdAt: deps.now(),
|
|
379
|
+
finishedAt: null,
|
|
380
|
+
reference: {
|
|
381
|
+
path: referencePath,
|
|
382
|
+
mediaType: reference.mediaType,
|
|
383
|
+
label: reference.label ?? basename(reference.path),
|
|
384
|
+
},
|
|
385
|
+
lanes: [],
|
|
386
|
+
winner: null,
|
|
387
|
+
picked: null,
|
|
388
|
+
error: null,
|
|
389
|
+
parent: agent,
|
|
390
|
+
}
|
|
391
|
+
this.clones.set(id, clone)
|
|
392
|
+
this.order.unshift(id)
|
|
393
|
+
|
|
394
|
+
await this.prepareRoot(clone).catch((error) => {
|
|
395
|
+
deps.logger?.warn?.(`dsh-clone: could not prepare the clone root: ${reasonOf(error)}`)
|
|
396
|
+
})
|
|
397
|
+
|
|
398
|
+
for (let index = 0; index < lanes; index += 1) {
|
|
399
|
+
const lane = {
|
|
400
|
+
index,
|
|
401
|
+
strategy: strategyForLane(index),
|
|
402
|
+
status: 'queued',
|
|
403
|
+
dir: join(root, `lane-${index + 1}`),
|
|
404
|
+
childId: null,
|
|
405
|
+
startedAt: null,
|
|
406
|
+
finishedAt: null,
|
|
407
|
+
tools: 0,
|
|
408
|
+
tokens: { input: 0, output: 0, cacheRead: 0 },
|
|
409
|
+
files: [],
|
|
410
|
+
stopReason: null,
|
|
411
|
+
error: null,
|
|
412
|
+
report: null,
|
|
413
|
+
previewUrl: `/api/dsh-clone/preview/${id}/${index + 1}/`,
|
|
414
|
+
}
|
|
415
|
+
clone.lanes.push(lane)
|
|
416
|
+
try {
|
|
417
|
+
await this.prepareLane(clone, lane)
|
|
418
|
+
await this.launchLane(clone, lane)
|
|
419
|
+
} catch (error) {
|
|
420
|
+
lane.status = 'failed'
|
|
421
|
+
lane.error = reasonOf(error)
|
|
422
|
+
lane.finishedAt = deps.now()
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// Lanes are launched one at a time, so a lane that fails immediately can
|
|
427
|
+
// report its terminal state before the remaining lanes even exist. The
|
|
428
|
+
// `starting` latch keeps `finishIfDone` from closing a clone that is still
|
|
429
|
+
// being populated; the check below runs after the last lane is launched.
|
|
430
|
+
clone.starting = false
|
|
431
|
+
this.finishIfDone(clone)
|
|
432
|
+
await this.saveState(clone)
|
|
433
|
+
return clone
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Decide the render/viewport size for this clone.
|
|
438
|
+
*
|
|
439
|
+
* `auto` means "the reference's own pixel size": building and rendering at
|
|
440
|
+
* exactly the reference's aspects is what makes the similarity score a fair
|
|
441
|
+
* comparison instead of two differently-shaped pictures of the same page.
|
|
442
|
+
* @param configured - the configured viewport, or `auto`.
|
|
443
|
+
* @param referencePath - the copied reference image.
|
|
444
|
+
* @returns e.g. `900x620`.
|
|
445
|
+
*/
|
|
446
|
+
async resolveViewport(configured, referencePath) {
|
|
447
|
+
if (typeof configured === 'string' && /^\d{2,5}x\d{2,5}$/u.test(configured)) return configured
|
|
448
|
+
try {
|
|
449
|
+
const size = readPngSize(await this.deps.fs.readFile(referencePath))
|
|
450
|
+
if (size !== null) return `${size.width}x${size.height}`
|
|
451
|
+
} catch {
|
|
452
|
+
/* fall through to the default */
|
|
453
|
+
}
|
|
454
|
+
return '1280x800'
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/** Resolve the repository facts for a workspace, if it is a repository. */
|
|
458
|
+
async inspectRepo(workspace) {
|
|
459
|
+
try {
|
|
460
|
+
const top = await this.deps.git(['rev-parse', '--show-toplevel'], workspace)
|
|
461
|
+
if (top.code !== 0) return null
|
|
462
|
+
const root = top.stdout.trim()
|
|
463
|
+
if (root.length === 0) return null
|
|
464
|
+
const head = await this.deps.git(['rev-parse', 'HEAD'], root)
|
|
465
|
+
if (head.code !== 0) return null
|
|
466
|
+
const gitDir = await this.deps.git(['rev-parse', '--git-common-dir'], root)
|
|
467
|
+
return {
|
|
468
|
+
root,
|
|
469
|
+
base: head.stdout.trim(),
|
|
470
|
+
gitDir: gitDir.code === 0 ? gitDir.stdout.trim() : join(root, '.git'),
|
|
471
|
+
}
|
|
472
|
+
} catch {
|
|
473
|
+
return null
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/** Hide the plugin's own scratch directory from the repository's status. */
|
|
478
|
+
async prepareRoot(clone) {
|
|
479
|
+
const { repo, workspace } = clone
|
|
480
|
+
if (repo === null) return
|
|
481
|
+
const exclude = join(repo.gitDir.startsWith('/') ? repo.gitDir : join(repo.root, repo.gitDir), 'info', 'exclude')
|
|
482
|
+
try {
|
|
483
|
+
await this.deps.fs.mkdir(join(exclude, '..'), { recursive: true })
|
|
484
|
+
const current = await this.deps.fs.readFile(exclude, 'utf8').catch(() => '')
|
|
485
|
+
const line = `${workspace === repo.root ? '' : '/'}${'.dsh-clone'}/`
|
|
486
|
+
if (!current.includes('.dsh-clone')) {
|
|
487
|
+
await this.deps.fs.writeFile(exclude, `${current}${current.endsWith('\n') || current === '' ? '' : '\n'}${line}\n`)
|
|
488
|
+
}
|
|
489
|
+
} catch {
|
|
490
|
+
/* The exclude file is a convenience; a failure must not stop the race. */
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/** Create one lane's directory: a git worktree in a repo, a plain dir otherwise. */
|
|
495
|
+
async prepareLane(clone, lane) {
|
|
496
|
+
const deps = this.deps
|
|
497
|
+
await deps.fs.mkdir(lane.dir, { recursive: true })
|
|
498
|
+
if (clone.repo !== null) {
|
|
499
|
+
const branch = `dsh-clone/${clone.id}-lane-${lane.index + 1}`
|
|
500
|
+
const added = await deps.git(['worktree', 'add', '-b', branch, lane.dir, clone.repo.base], clone.repo.root)
|
|
501
|
+
if (added.code !== 0) {
|
|
502
|
+
lane.error = reasonOf(added.stderr || added.stdout)
|
|
503
|
+
throw new Error(`could not create worktree: ${lane.error}`)
|
|
504
|
+
}
|
|
505
|
+
lane.branch = branch
|
|
506
|
+
}
|
|
507
|
+
await this.linkSharedDependencies(clone, lane)
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/** Reuse the workspace's dependency directories instead of reinstalling them. */
|
|
511
|
+
async linkSharedDependencies(clone, lane) {
|
|
512
|
+
const source = clone.repo?.root ?? clone.workspace
|
|
513
|
+
if (source === lane.dir) return
|
|
514
|
+
for (const name of SHARED_DIRECTORIES) {
|
|
515
|
+
const from = join(source, name)
|
|
516
|
+
const to = join(lane.dir, name)
|
|
517
|
+
const hasSource = await this.deps.fs.stat(from).then(() => true, () => false)
|
|
518
|
+
if (!hasSource) continue
|
|
519
|
+
const hasTarget = await this.deps.fs.stat(to).then(() => true, () => false)
|
|
520
|
+
if (hasTarget) continue
|
|
521
|
+
await this.deps.fs.symlink(from, to, 'dir').catch(() => {})
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/** Launch one lane's builder agent. */
|
|
526
|
+
async launchLane(clone, lane) {
|
|
527
|
+
const deps = this.deps
|
|
528
|
+
const prompt = buildLanePrompt({
|
|
529
|
+
laneIndex: lane.index,
|
|
530
|
+
laneCount: clone.lanes.length,
|
|
531
|
+
strategy: lane.strategy,
|
|
532
|
+
referencePath: clone.reference.path,
|
|
533
|
+
referenceLabel: clone.reference.label,
|
|
534
|
+
viewport: clone.viewport,
|
|
535
|
+
note: clone.note,
|
|
536
|
+
})
|
|
537
|
+
const handle = await deps.spawnAgent({
|
|
538
|
+
cwd: lane.dir,
|
|
539
|
+
prompt,
|
|
540
|
+
label: `clone lane ${lane.index + 1}`,
|
|
541
|
+
parent: clone.parent,
|
|
542
|
+
})
|
|
543
|
+
lane.childId = handle.childId ?? null
|
|
544
|
+
lane.status = 'running'
|
|
545
|
+
lane.startedAt = deps.now()
|
|
546
|
+
this.armLaneTimer(clone, lane)
|
|
547
|
+
Promise.resolve(handle.result).then(
|
|
548
|
+
(result) => this.onLaneSettled(clone, lane, result, handle),
|
|
549
|
+
(error) => this.onLaneFailed(clone, lane, error, handle),
|
|
550
|
+
)
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/** A lane's builder finished: inventory its output and make it adoptable. */
|
|
554
|
+
async onLaneSettled(clone, lane, result, handle) {
|
|
555
|
+
const deps = this.deps
|
|
556
|
+
this.clearLaneTimer(clone, lane)
|
|
557
|
+
if (lane.status !== 'running') {
|
|
558
|
+
Promise.resolve(handle?.dispose?.()).catch(() => {})
|
|
559
|
+
return
|
|
560
|
+
}
|
|
561
|
+
try {
|
|
562
|
+
lane.finishedAt = deps.now()
|
|
563
|
+
lane.stopReason = result?.stopReason ?? 'completed'
|
|
564
|
+
lane.report = extractReport(result)
|
|
565
|
+
lane.files = await this.inventory(lane.dir)
|
|
566
|
+
const delivered = lane.files.some((file) => file.path === 'index.html')
|
|
567
|
+
if (!delivered) {
|
|
568
|
+
lane.status = 'empty'
|
|
569
|
+
lane.error = 'the lane produced no index.html'
|
|
570
|
+
} else {
|
|
571
|
+
// `scoring` is deliberately not a terminal state: a clone that closed
|
|
572
|
+
// while a render was still running would hand the panel a missing
|
|
573
|
+
// score, and the panel stops polling once the clone is finished.
|
|
574
|
+
lane.status = 'scoring'
|
|
575
|
+
await this.scoreLane(clone, lane)
|
|
576
|
+
lane.status = 'settled'
|
|
577
|
+
}
|
|
578
|
+
} catch (error) {
|
|
579
|
+
lane.status = 'failed'
|
|
580
|
+
lane.error = reasonOf(error)
|
|
581
|
+
} finally {
|
|
582
|
+
Promise.resolve(handle?.dispose?.()).catch(() => {})
|
|
583
|
+
await this.saveState(clone).catch(() => {})
|
|
584
|
+
this.finishIfDone(clone)
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* Render a delivered lane and score it against the reference.
|
|
590
|
+
*
|
|
591
|
+
* Optional at every step: no browser, a render that never lands, or bytes the
|
|
592
|
+
* decoder refuses all leave the lane unscored instead of failing the clone.
|
|
593
|
+
* @param clone - owning clone.
|
|
594
|
+
* @param lane - the lane that just delivered.
|
|
595
|
+
*/
|
|
596
|
+
async scoreLane(clone, lane) {
|
|
597
|
+
const deps = this.deps
|
|
598
|
+
if (typeof deps.scoreLane !== 'function') return
|
|
599
|
+
try {
|
|
600
|
+
const scored = await deps.scoreLane({
|
|
601
|
+
laneDir: lane.dir,
|
|
602
|
+
referencePath: clone.reference.path,
|
|
603
|
+
outPath: join(clone.root, 'renders', `lane-${lane.index + 1}.png`),
|
|
604
|
+
viewport: clone.viewport,
|
|
605
|
+
})
|
|
606
|
+
if (scored === null || scored === undefined) return
|
|
607
|
+
lane.renderPath = scored.outPath
|
|
608
|
+
lane.score = scored.score
|
|
609
|
+
} catch (error) {
|
|
610
|
+
deps.logger?.warn?.(`dsh-clone: lane ${lane.index + 1} was not scored: ${reasonOf(error)}`)
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/** A lane's builder failed or was interrupted. */
|
|
615
|
+
onLaneFailed(clone, lane, error, handle) {
|
|
616
|
+
this.clearLaneTimer(clone, lane)
|
|
617
|
+
if (lane.status !== 'running') {
|
|
618
|
+
Promise.resolve(handle?.dispose?.()).catch(() => {})
|
|
619
|
+
return
|
|
620
|
+
}
|
|
621
|
+
lane.status = lane.status === 'cancelled' ? 'cancelled' : 'failed'
|
|
622
|
+
lane.error = reasonOf(error)
|
|
623
|
+
lane.finishedAt = this.deps.now()
|
|
624
|
+
Promise.resolve(handle?.dispose?.()).catch(() => {})
|
|
625
|
+
Promise.resolve(this.saveState(clone)).catch(() => {})
|
|
626
|
+
this.finishIfDone(clone)
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/** Refresh live counters for every running lane. */
|
|
630
|
+
tick() {
|
|
631
|
+
const deps = this.deps
|
|
632
|
+
for (const clone of this.clones.values()) {
|
|
633
|
+
if (clone.status !== 'running') continue
|
|
634
|
+
for (const lane of clone.lanes) {
|
|
635
|
+
if (lane.status !== 'running' || lane.childId === null) continue
|
|
636
|
+
try {
|
|
637
|
+
const agent = deps.getAgent(lane.childId)
|
|
638
|
+
if (agent === undefined || agent === null) continue
|
|
639
|
+
const events = agent.session.snapshotEvents()
|
|
640
|
+
let tools = 0
|
|
641
|
+
const tokens = { input: 0, output: 0, cacheRead: 0 }
|
|
642
|
+
for (const event of events) {
|
|
643
|
+
if (event.type === 'tool/call') tools += 1
|
|
644
|
+
const usage = event.type === 'assistant/message' ? event.data.usage : undefined
|
|
645
|
+
if (usage === undefined) continue
|
|
646
|
+
tokens.input += Number(usage.inputTokens) || 0
|
|
647
|
+
tokens.output += Number(usage.outputTokens) || 0
|
|
648
|
+
tokens.cacheRead += Number(usage.cacheReadTokens) || 0
|
|
649
|
+
}
|
|
650
|
+
lane.tools = tools
|
|
651
|
+
lane.tokens = tokens
|
|
652
|
+
this.maybeNudge(clone, lane)
|
|
653
|
+
const budget = Number(deps.config?.laneTokenBudget)
|
|
654
|
+
if (Number.isFinite(budget) && budget > 0 && totalTokens(tokens) > budget) {
|
|
655
|
+
lane.stopReason = 'token-budget'
|
|
656
|
+
Promise.resolve(this.forceSettle(clone, lane, 'token-budget')).catch(() => {})
|
|
657
|
+
}
|
|
658
|
+
} catch {
|
|
659
|
+
/* A lane whose session is mid-teardown simply keeps its last numbers. */
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
/**
|
|
666
|
+
* Push a lane that is still analysing towards writing the page.
|
|
667
|
+
*
|
|
668
|
+
* Some models answer "measure first" by sampling pixels for the entire budget
|
|
669
|
+
* and then ending with nothing delivered. One steering message at a known tool
|
|
670
|
+
* count is cheap, and turns a wasted run into a delivered page.
|
|
671
|
+
* @param clone - owning clone.
|
|
672
|
+
* @param lane - a running lane.
|
|
673
|
+
*/
|
|
674
|
+
maybeNudge(clone, lane) {
|
|
675
|
+
const deps = this.deps
|
|
676
|
+
if (typeof deps.nudgeLane !== 'function') return
|
|
677
|
+
const at = Number(deps.config?.nudgeAtTools)
|
|
678
|
+
const threshold = Number.isFinite(at) && at > 0 ? at : 12
|
|
679
|
+
if (lane.nudged === true || lane.nudgeChecked === true || lane.tools < threshold) return
|
|
680
|
+
lane.nudgeChecked = true
|
|
681
|
+
// The lane's file list is only filled when it settles, so ask the disk. A
|
|
682
|
+
// lane that already wrote its page is polishing, not stalling — leave it be.
|
|
683
|
+
Promise.resolve((async () => {
|
|
684
|
+
const delivered = await deps.fs.stat(join(lane.dir, 'index.html')).then(() => true, () => false)
|
|
685
|
+
if (delivered || lane.status !== 'running') return
|
|
686
|
+
lane.nudged = true
|
|
687
|
+
await deps.nudgeLane({
|
|
688
|
+
childId: lane.childId,
|
|
689
|
+
parent: clone.parent,
|
|
690
|
+
text: [
|
|
691
|
+
`Stop analysing and write the page now. You have made ${lane.tools} tool calls and index.html does not exist yet.`,
|
|
692
|
+
'Approximate colours and spacing by eye from the reference; do not run any more image-measurement scripts.',
|
|
693
|
+
'Write a complete index.html in your next tool call, then refine it if you still have budget.',
|
|
694
|
+
].join(' '),
|
|
695
|
+
})
|
|
696
|
+
})()).catch((error) => {
|
|
697
|
+
deps.logger?.warn?.(`dsh-clone: could not nudge lane ${lane.index + 1}: ${reasonOf(error)}`)
|
|
698
|
+
})
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
/** Close a clone once every lane reached a terminal state. */
|
|
702
|
+
finishIfDone(clone) {
|
|
703
|
+
if (clone.status !== 'running') return
|
|
704
|
+
if (clone.starting === true) return
|
|
705
|
+
const terminal = clone.lanes.every((lane) => ['settled', 'empty', 'failed', 'cancelled'].includes(lane.status))
|
|
706
|
+
if (terminal) this.finish(clone)
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
/** Mark a clone finished and compute the suggested winner. */
|
|
710
|
+
finish(clone) {
|
|
711
|
+
clone.status = 'finished'
|
|
712
|
+
clone.finishedAt = this.deps.now()
|
|
713
|
+
const winner = suggestedWinner(clone.lanes)
|
|
714
|
+
clone.winner = winner === null ? null : winner.index
|
|
715
|
+
if (clone.kind === 'refine' && typeof clone.sourceScore === 'number') {
|
|
716
|
+
const lane = winner ?? clone.lanes[0]
|
|
717
|
+
clone.delta = typeof lane?.score === 'number'
|
|
718
|
+
? Math.round((lane.score - clone.sourceScore) * 10) / 10
|
|
719
|
+
: null
|
|
720
|
+
}
|
|
721
|
+
Promise.resolve(this.saveState(clone)).catch(() => {})
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
/** Walk a lane directory into the flat inventory the panel renders. */
|
|
725
|
+
async inventory(dir, relative = '', out = []) {
|
|
726
|
+
if (out.length >= LIMITS.filesPerLane) return out
|
|
727
|
+
let entries
|
|
728
|
+
try {
|
|
729
|
+
entries = await this.deps.fs.readdir(dir, { withFileTypes: true })
|
|
730
|
+
} catch {
|
|
731
|
+
return out
|
|
732
|
+
}
|
|
733
|
+
const sorted = [...entries].sort((left, right) => left.name.localeCompare(right.name))
|
|
734
|
+
for (const entry of sorted) {
|
|
735
|
+
if (out.length >= LIMITS.filesPerLane) break
|
|
736
|
+
if (isScratchEntry(entry.name)) continue
|
|
737
|
+
const rel = relative.length === 0 ? entry.name : `${relative}/${entry.name}`
|
|
738
|
+
const absolute = join(dir, entry.name)
|
|
739
|
+
if (entry.isDirectory()) {
|
|
740
|
+
await this.inventory(absolute, rel, out)
|
|
741
|
+
continue
|
|
742
|
+
}
|
|
743
|
+
const stats = await this.deps.fs.stat(absolute).catch(() => null)
|
|
744
|
+
out.push({ path: rel, bytes: stats === null ? 0 : stats.size })
|
|
745
|
+
}
|
|
746
|
+
return out
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
/**
|
|
750
|
+
* Refine an adopted lane instead of starting over.
|
|
751
|
+
*
|
|
752
|
+
* The previous page is copied into a fresh single-lane clone and a builder is
|
|
753
|
+
* asked for the smallest edit that satisfies the change. Because the reference
|
|
754
|
+
* and the viewport are unchanged, the result is directly comparable: the clone
|
|
755
|
+
* carries the source lane's score so the panel can show the delta.
|
|
756
|
+
* @param args - source clone, lane number (defaults to its winner), the change
|
|
757
|
+
* requested, and the calling agent.
|
|
758
|
+
* @returns the new clone.
|
|
759
|
+
*/
|
|
760
|
+
async refine({ cloneId, laneNumber, agent, change, viewport }) {
|
|
761
|
+
const deps = this.deps
|
|
762
|
+
const source = this.clones.get(cloneId)
|
|
763
|
+
if (source === undefined) throw new Error(`dsh-clone: unknown clone "${cloneId}"`)
|
|
764
|
+
const sourceLane = laneNumber === null || laneNumber === undefined
|
|
765
|
+
? source.lanes.find((candidate) => candidate.index === source.winner) ?? source.lanes[0]
|
|
766
|
+
: source.lanes[laneNumber - 1]
|
|
767
|
+
if (sourceLane === undefined || sourceLane === null || !sourceLane.files.some((file) => file.path === 'index.html')) {
|
|
768
|
+
throw new Error('dsh-clone: that lane has no page to refine')
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
const workspace = source.workspace ?? await deps.workingDirectory.ensure(agent)
|
|
772
|
+
const id = deps.id().replace(/[^a-z0-9]/giu, '').slice(0, 8)
|
|
773
|
+
const root = join(workspace, '.dsh-clone', id)
|
|
774
|
+
await deps.fs.mkdir(root, { recursive: true })
|
|
775
|
+
const referencePath = join(root, `reference${source.reference.path.endsWith('.png') ? '.png' : ''}`)
|
|
776
|
+
await deps.fs.copyFile(source.reference.path, referencePath)
|
|
777
|
+
|
|
778
|
+
const targetViewport = viewport ?? source.viewport
|
|
779
|
+
const clone = {
|
|
780
|
+
id,
|
|
781
|
+
sessionId: source.sessionId,
|
|
782
|
+
workspace,
|
|
783
|
+
root,
|
|
784
|
+
repo: source.repo,
|
|
785
|
+
note: truncate(String(change ?? ''), 300),
|
|
786
|
+
viewport: targetViewport,
|
|
787
|
+
status: 'running',
|
|
788
|
+
starting: true,
|
|
789
|
+
createdAt: deps.now(),
|
|
790
|
+
finishedAt: null,
|
|
791
|
+
reference: { path: referencePath, mediaType: source.reference.mediaType, label: source.reference.label },
|
|
792
|
+
lanes: [],
|
|
793
|
+
winner: null,
|
|
794
|
+
picked: null,
|
|
795
|
+
error: null,
|
|
796
|
+
parent: agent,
|
|
797
|
+
kind: 'refine',
|
|
798
|
+
sourceId: source.id,
|
|
799
|
+
sourceScore: typeof sourceLane.score === 'number' ? sourceLane.score : null,
|
|
800
|
+
}
|
|
801
|
+
this.clones.set(id, clone)
|
|
802
|
+
this.order.unshift(id)
|
|
803
|
+
|
|
804
|
+
const lane = {
|
|
805
|
+
index: 0,
|
|
806
|
+
strategy: strategyForLane(0),
|
|
807
|
+
status: 'queued',
|
|
808
|
+
dir: join(root, 'lane-1'),
|
|
809
|
+
childId: null,
|
|
810
|
+
startedAt: null,
|
|
811
|
+
finishedAt: null,
|
|
812
|
+
tools: 0,
|
|
813
|
+
tokens: { input: 0, output: 0, cacheRead: 0 },
|
|
814
|
+
files: [],
|
|
815
|
+
stopReason: null,
|
|
816
|
+
error: null,
|
|
817
|
+
report: null,
|
|
818
|
+
previewUrl: `/api/dsh-clone/preview/${id}/1/`,
|
|
819
|
+
}
|
|
820
|
+
clone.lanes.push(lane)
|
|
821
|
+
clone.starting = false
|
|
822
|
+
|
|
823
|
+
try {
|
|
824
|
+
await deps.fs.mkdir(lane.dir, { recursive: true })
|
|
825
|
+
await deps.fs.cp(sourceLane.dir, lane.dir, {
|
|
826
|
+
recursive: true,
|
|
827
|
+
force: true,
|
|
828
|
+
filter: (path) => !isScratchEntry(basename(path)),
|
|
829
|
+
})
|
|
830
|
+
const prompt = buildRefinePrompt({
|
|
831
|
+
referencePath,
|
|
832
|
+
change: String(change ?? ''),
|
|
833
|
+
viewport: targetViewport,
|
|
834
|
+
})
|
|
835
|
+
const handle = await deps.spawnAgent({
|
|
836
|
+
cwd: lane.dir,
|
|
837
|
+
prompt,
|
|
838
|
+
label: 'clone refine',
|
|
839
|
+
parent: agent,
|
|
840
|
+
})
|
|
841
|
+
lane.childId = handle.childId ?? null
|
|
842
|
+
lane.status = 'running'
|
|
843
|
+
lane.startedAt = deps.now()
|
|
844
|
+
this.armLaneTimer(clone, lane)
|
|
845
|
+
Promise.resolve(handle.result).then(
|
|
846
|
+
(result) => this.onLaneSettled(clone, lane, result, handle),
|
|
847
|
+
(error) => this.onLaneFailed(clone, lane, error, handle),
|
|
848
|
+
)
|
|
849
|
+
} catch (error) {
|
|
850
|
+
lane.status = 'failed'
|
|
851
|
+
lane.error = reasonOf(error)
|
|
852
|
+
lane.finishedAt = deps.now()
|
|
853
|
+
this.finishIfDone(clone)
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
await this.saveState(clone).catch(() => {})
|
|
857
|
+
return clone
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
/**
|
|
861
|
+
* Adopt one lane: copy its files into a fresh folder in the workspace.
|
|
862
|
+
* @param args - clone id, optional 1-based lane number (defaults to the suggestion).
|
|
863
|
+
* @returns `{ path, lane }` on success.
|
|
864
|
+
*/
|
|
865
|
+
async pick({ cloneId, laneNumber }) {
|
|
866
|
+
const deps = this.deps
|
|
867
|
+
const clone = this.clones.get(cloneId)
|
|
868
|
+
if (clone === undefined) throw new Error(`dsh-clone: unknown clone "${cloneId}"`)
|
|
869
|
+
if (clone.picked !== null) return { path: clone.picked.path, lane: clone.picked.lane }
|
|
870
|
+
|
|
871
|
+
const lane = laneNumber === null || laneNumber === undefined
|
|
872
|
+
? clone.lanes.find((candidate) => candidate.index === clone.winner)
|
|
873
|
+
: clone.lanes[laneNumber - 1]
|
|
874
|
+
if (lane === undefined || lane === null) throw new Error('dsh-clone: that lane does not exist')
|
|
875
|
+
if (!['settled', 'empty'].includes(lane.status) || lane.files.length === 0) {
|
|
876
|
+
throw new Error(`dsh-clone: lane ${lane.index + 1} has nothing to adopt (${lane.status})`)
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
const target = join(clone.workspace, `clone-${clone.id}-lane-${lane.index + 1}`)
|
|
880
|
+
await deps.fs.mkdir(target, { recursive: true })
|
|
881
|
+
await deps.fs.cp(lane.dir, target, {
|
|
882
|
+
recursive: true,
|
|
883
|
+
force: true,
|
|
884
|
+
filter: (source) => !isScratchEntry(basename(source)),
|
|
885
|
+
})
|
|
886
|
+
clone.picked = {
|
|
887
|
+
lane: lane.index,
|
|
888
|
+
path: target,
|
|
889
|
+
at: deps.now(),
|
|
890
|
+
}
|
|
891
|
+
await this.saveState(clone).catch(() => {})
|
|
892
|
+
return { path: target, lane }
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
/** Interrupt running builders and close the clone. */
|
|
896
|
+
async cancel({ cloneId }) {
|
|
897
|
+
const clone = this.clones.get(cloneId)
|
|
898
|
+
if (clone === undefined) throw new Error(`dsh-clone: unknown clone "${cloneId}"`)
|
|
899
|
+
for (const lane of clone.lanes) {
|
|
900
|
+
this.clearLaneTimer(clone, lane)
|
|
901
|
+
if (lane.status !== 'running') continue
|
|
902
|
+
lane.status = 'cancelled'
|
|
903
|
+
lane.finishedAt = this.deps.now()
|
|
904
|
+
try {
|
|
905
|
+
this.deps.interruptAgent({ childId: lane.childId, parent: clone.parent })
|
|
906
|
+
} catch (error) {
|
|
907
|
+
lane.error = reasonOf(error)
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
this.finish(clone)
|
|
911
|
+
return clone
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
/** Remove a clone's scratch directories from the workspace. */
|
|
915
|
+
async discard({ cloneId }) {
|
|
916
|
+
const deps = this.deps
|
|
917
|
+
const clone = this.clones.get(cloneId)
|
|
918
|
+
if (clone === undefined) throw new Error(`dsh-clone: unknown clone "${cloneId}"`)
|
|
919
|
+
for (const lane of clone.lanes) this.clearLaneTimer(clone, lane)
|
|
920
|
+
if (clone.status === 'running') await this.cancel({ cloneId })
|
|
921
|
+
if (clone.repo !== null) {
|
|
922
|
+
for (const lane of clone.lanes) {
|
|
923
|
+
if (lane.branch === undefined) continue
|
|
924
|
+
await deps.git(['worktree', 'remove', '--force', lane.dir], clone.repo.root).catch(() => {})
|
|
925
|
+
await deps.git(['branch', '-D', lane.branch], clone.repo.root).catch(() => {})
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
await deps.fs.rm(clone.root, { recursive: true, force: true }).catch(() => {})
|
|
929
|
+
clone.status = 'discarded'
|
|
930
|
+
return clone
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
/** JSON-safe projection of one clone for the Web panel and the tools. */
|
|
934
|
+
snapshot(clone) {
|
|
935
|
+
if (clone === undefined || clone === null) return null
|
|
936
|
+
const now = this.deps.now()
|
|
937
|
+
return {
|
|
938
|
+
id: clone.id,
|
|
939
|
+
status: clone.status,
|
|
940
|
+
note: clone.note,
|
|
941
|
+
viewport: clone.viewport,
|
|
942
|
+
createdAt: clone.createdAt,
|
|
943
|
+
finishedAt: clone.finishedAt,
|
|
944
|
+
durationMs: (clone.finishedAt ?? now) - clone.createdAt,
|
|
945
|
+
repo: clone.repo === null ? null : { root: clone.repo.root },
|
|
946
|
+
reference: {
|
|
947
|
+
url: `/api/dsh-clone/reference?cloneId=${clone.id}`,
|
|
948
|
+
label: clone.reference.label,
|
|
949
|
+
mediaType: clone.reference.mediaType,
|
|
950
|
+
},
|
|
951
|
+
winner: clone.winner,
|
|
952
|
+
picked: clone.picked,
|
|
953
|
+
error: clone.error,
|
|
954
|
+
kind: clone.kind ?? 'clone',
|
|
955
|
+
sourceId: clone.sourceId ?? null,
|
|
956
|
+
sourceScore: typeof clone.sourceScore === 'number' ? clone.sourceScore : null,
|
|
957
|
+
delta: typeof clone.delta === 'number' ? clone.delta : null,
|
|
958
|
+
restored: clone.restored === true,
|
|
959
|
+
lanes: clone.lanes.map((lane) => ({
|
|
960
|
+
index: lane.index,
|
|
961
|
+
strategy: lane.strategy.id,
|
|
962
|
+
strategyLabel: lane.strategy.label,
|
|
963
|
+
status: lane.status,
|
|
964
|
+
previewUrl: lane.previewUrl,
|
|
965
|
+
startedAt: lane.startedAt,
|
|
966
|
+
finishedAt: lane.finishedAt,
|
|
967
|
+
durationMs: lane.startedAt === null ? null : (lane.finishedAt ?? now) - lane.startedAt,
|
|
968
|
+
tools: lane.tools,
|
|
969
|
+
tokens: lane.tokens,
|
|
970
|
+
files: lane.files.length,
|
|
971
|
+
bytes: lane.files.reduce((sum, file) => sum + (Number(file.bytes) || 0), 0),
|
|
972
|
+
delivered: lane.files.some((file) => file.path === 'index.html'),
|
|
973
|
+
score: typeof lane.score === 'number' ? lane.score : null,
|
|
974
|
+
renderUrl: lane.renderPath === undefined
|
|
975
|
+
? null
|
|
976
|
+
: `/api/dsh-clone/render?cloneId=${clone.id}&lane=${lane.index + 1}`,
|
|
977
|
+
stopReason: lane.stopReason,
|
|
978
|
+
timedOut: lane.timedOut === true,
|
|
979
|
+
nudged: lane.nudged === true,
|
|
980
|
+
error: lane.error,
|
|
981
|
+
report: lane.report === null || lane.report === undefined ? null : truncate(lane.report, 1200),
|
|
982
|
+
})),
|
|
983
|
+
summary: summarize(clone),
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
/** Pull the readable closing text out of a subagent result. */
|
|
989
|
+
function extractReport(result) {
|
|
990
|
+
const blocks = Array.isArray(result?.output) ? result.output : []
|
|
991
|
+
const text = blocks
|
|
992
|
+
.filter((block) => block?.type === 'text' && typeof block.text === 'string')
|
|
993
|
+
.map((block) => block.text)
|
|
994
|
+
.join('\n')
|
|
995
|
+
.trim()
|
|
996
|
+
return text.length === 0 ? null : text
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
export { LIMITS, IGNORED_NAMES }
|