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/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 }