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.
Binary file
package/icon.svg ADDED
@@ -0,0 +1,29 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="dsh-clone">
2
+ <defs>
3
+ <linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
4
+ <stop offset="0" stop-color="#111a33"/>
5
+ <stop offset="1" stop-color="#0a1020"/>
6
+ </linearGradient>
7
+ <linearGradient id="frame" x1="0" y1="0" x2="1" y2="1">
8
+ <stop offset="0" stop-color="#7aa2ff"/>
9
+ <stop offset="1" stop-color="#4d6bff"/>
10
+ </linearGradient>
11
+ </defs>
12
+
13
+ <rect width="64" height="64" rx="15" fill="url(#bg)"/>
14
+
15
+ <!-- reference frame: dashed, the screenshot you show -->
16
+ <rect x="9.5" y="15.5" width="24" height="18" rx="4.5" fill="none" stroke="#7aa2ff" stroke-opacity="0.55" stroke-width="2" stroke-dasharray="3.5 3"/>
17
+
18
+ <!-- rebuilt frame: solid, what the agents ship -->
19
+ <rect x="27" y="27" width="27" height="21" rx="5" fill="#0a1020" stroke="url(#frame)" stroke-width="2.6"/>
20
+
21
+ <!-- the rebuilt UI inside the shipped frame -->
22
+ <rect x="32" y="32" width="11" height="2.6" rx="1.3" fill="url(#frame)"/>
23
+ <rect x="32" y="37.5" width="17" height="2.2" rx="1.1" fill="#7aa2ff" fill-opacity="0.55"/>
24
+ <rect x="32" y="42" width="13" height="2.2" rx="1.1" fill="#7aa2ff" fill-opacity="0.35"/>
25
+
26
+ <!-- copy arrow from reference to rebuild -->
27
+ <path d="M23 42c2.4 2.2 4.4 3.2 6.6 3.2" fill="none" stroke="#7aa2ff" stroke-opacity="0.6" stroke-width="2.2" stroke-linecap="round"/>
28
+ <path d="M27.4 43.2l2.6 2.1-3.2 1.2z" fill="#7aa2ff" fill-opacity="0.6"/>
29
+ </svg>
package/index.js ADDED
@@ -0,0 +1,626 @@
1
+ /**
2
+ * dsh-clone — Host half.
3
+ *
4
+ * Screenshot in, working UI out. One command starts N builder agents, each in
5
+ * its own isolated directory (a git worktree when the workspace is a repo,
6
+ * a plain directory when it is not), all rebuilding the same reference image
7
+ * in parallel. The Web panel shows the reference and every lane side by side,
8
+ * live, and the reader adopts whichever rebuild they prefer.
9
+ *
10
+ * Wiring, in one place:
11
+ * /clone command human entry point
12
+ * clone tool the same operations for the agent
13
+ * static routes panel state, reference image, lane previews, actions
14
+ * poller live tool/token counters while a clone is running
15
+ *
16
+ * Every side effect the engine needs is injected below, so lib/engine.js stays
17
+ * a pure state machine and test/smoke.mjs can drive it without a Harness.
18
+ */
19
+
20
+ import { basename, dirname, extname, resolve as resolvePath } from 'node:path'
21
+ import { cp, copyFile, mkdir, readFile, readdir, realpath, rm, stat, symlink, writeFile } from 'node:fs/promises'
22
+ import { CloneEngine } from './lib/engine.js'
23
+ import { parseCloneArgs, referenceHintOf } from './lib/parse.js'
24
+ import { makeScoreLane } from './lib/score-lane.js'
25
+ import { clampLanes, mediaTypeOf, reasonOf, truncate } from './lib/util.js'
26
+ import {
27
+ parsePreviewPath,
28
+ readJsonBody,
29
+ sendJson,
30
+ sendText,
31
+ serveImageFile,
32
+ serveLaneFile,
33
+ } from './lib/preview.js'
34
+
35
+ /** Cordis plugin identity. */
36
+ export const name = 'dsh-clone'
37
+
38
+ /** Services that must exist for the plugin to be useful. */
39
+ export const inject = ['commands', 'tools', 'workingDirectory', 'subagents', 'agents', 'subprocess']
40
+
41
+ /** Tunables, read defensively so a partial or missing row config still works. */
42
+ export function resolveConfig(raw) {
43
+ const input = raw !== null && typeof raw === 'object' ? raw : {}
44
+ // `auto` (the default) means "use the reference image's own pixel size", which
45
+ // is what makes the similarity score a fair comparison. An explicit
46
+ // `1280x800`-style value overrides it.
47
+ const viewport = typeof input.viewport === 'string' && input.viewport.length > 0
48
+ ? input.viewport
49
+ : 'auto'
50
+ return {
51
+ lanes: clampLanes(input.lanes ?? 3, { min: 1, max: 4, fallback: 3 }),
52
+ maxLanes: clampLanes(input.maxLanes ?? 4, { min: 1, max: 4, fallback: 4 }),
53
+ viewport,
54
+ provider: typeof input.provider === 'string' && input.provider.length > 0 ? input.provider : 'spawn',
55
+ pollMs: Number.isFinite(input.pollMs) && input.pollMs >= 200 ? Math.floor(input.pollMs) : 900,
56
+ // Wall-clock limit per lane. A builder that keeps polishing past its budget
57
+ // is stopped and whatever it already wrote is delivered and scored.
58
+ laneTimeoutMs: Number.isFinite(input.laneTimeoutMs) && input.laneTimeoutMs >= 60_000
59
+ ? Math.floor(input.laneTimeoutMs)
60
+ : 15 * 60 * 1000,
61
+ // Token ceiling per lane. Off by default: an agent re-sends its context on
62
+ // every step, so cumulative input tokens grow fast enough that a naive
63
+ // default kills honest builds mid-analysis. Set it deliberately (2M+ is a
64
+ // sensible ceiling for one static page) when you want a hard backstop.
65
+ laneTokenBudget: Number.isFinite(input.laneTokenBudget) && input.laneTokenBudget > 0
66
+ ? Math.floor(input.laneTokenBudget)
67
+ : 0,
68
+ // Tool calls after which a lane that still has no index.html is told to stop
69
+ // analysing and write the page. 0 disables the nudge.
70
+ nudgeAtTools: Number.isFinite(input.nudgeAtTools) && input.nudgeAtTools >= 0
71
+ ? Math.floor(input.nudgeAtTools)
72
+ : 12,
73
+ commandName: typeof input.commandName === 'string' && input.commandName.length > 0 ? input.commandName : 'clone',
74
+ tool: input.tool !== false,
75
+ command: input.command !== false,
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Mount the plugin.
81
+ * @param ctx - Cordis context provided by the Loader.
82
+ * @param rawConfig - untrusted row configuration.
83
+ */
84
+ export async function apply(ctx, rawConfig) {
85
+ const config = resolveConfig(rawConfig)
86
+ const log = (level, message) => {
87
+ try {
88
+ ctx.logger?.[level]?.(`dsh-clone: ${message}`)
89
+ } catch {
90
+ /* logging must never break the plugin */
91
+ }
92
+ }
93
+
94
+ // ── side effects handed to the engine ────────────────────────────────────
95
+
96
+ const runCommand = async (argv, cwd, { input, timeoutMs = 120_000 } = {}) => {
97
+ const maxBytes = 4 * 1024 * 1024
98
+ const handle = ctx.subprocess.spawn({
99
+ argv,
100
+ cwd,
101
+ stdio: {
102
+ stdin: input === undefined ? 'ignore' : { data: input },
103
+ stdout: { maxBytes },
104
+ stderr: { maxBytes },
105
+ },
106
+ graceMs: 2_000,
107
+ })
108
+ const timer = setTimeout(() => {
109
+ try {
110
+ handle.terminate()
111
+ } catch {
112
+ /* already gone */
113
+ }
114
+ }, timeoutMs)
115
+ let outcome
116
+ try {
117
+ outcome = await handle.done
118
+ } finally {
119
+ clearTimeout(timer)
120
+ }
121
+ const stdout = handle.collected?.stdout?.readFrom(0)?.text ?? ''
122
+ const stderr = handle.collected?.stderr?.readFrom(0)?.text ?? ''
123
+ return {
124
+ code: typeof outcome?.exitCode === 'number' ? outcome.exitCode : outcome?.signal ? 124 : 1,
125
+ stdout,
126
+ stderr,
127
+ signal: outcome?.signal ?? null,
128
+ }
129
+ }
130
+
131
+ const resolveReference = async ({ agent, note }) => {
132
+ const workspace = await ctx.workingDirectory.ensure(agent)
133
+ const hint = referenceHintOf(note)
134
+ if (hint !== null) {
135
+ const absolute = resolvePath(workspace, hint)
136
+ const stats = await stat(absolute).catch(() => null)
137
+ if (stats !== null && stats.isFile()) {
138
+ return {
139
+ path: absolute,
140
+ mediaType: mediaTypeOf(absolute),
141
+ extension: extname(absolute) || '.png',
142
+ label: basename(absolute),
143
+ }
144
+ }
145
+ }
146
+ const attachments = ctx.get('attachments')
147
+ const events = agent.session.snapshotEvents()
148
+ for (let index = events.length - 1; index >= 0; index -= 1) {
149
+ const event = events[index]
150
+ if (event?.type !== 'user/message') continue
151
+ const content = Array.isArray(event.data?.content) ? event.data.content : []
152
+ for (let block = content.length - 1; block >= 0; block -= 1) {
153
+ if (content[block]?.type !== 'image') continue
154
+ const hostPath = attachments?.imageHostPath?.(content[block].attachment)
155
+ if (typeof hostPath === 'string' && hostPath.length > 0) {
156
+ return {
157
+ path: hostPath,
158
+ mediaType: content[block].attachment?.mediaType ?? mediaTypeOf(hostPath),
159
+ extension: extname(hostPath) || '.png',
160
+ label: 'conversation image',
161
+ }
162
+ }
163
+ }
164
+ }
165
+ return null
166
+ }
167
+
168
+ const activations = new Map()
169
+
170
+ const spawnAgent = async ({ cwd, prompt, label, parent }) => {
171
+ const activation = await ctx.subagents.startActivation({
172
+ provider: config.provider,
173
+ label,
174
+ request: {
175
+ cwd,
176
+ prompt: [{ type: 'text', text: prompt }],
177
+ parent,
178
+ },
179
+ signal: new AbortController().signal,
180
+ delivery: 'caller',
181
+ })
182
+ activations.set(activation.childId, activation)
183
+ Promise.resolve(activation.result).then(
184
+ () => activations.delete(activation.childId),
185
+ () => activations.delete(activation.childId),
186
+ )
187
+ return {
188
+ childId: activation.childId,
189
+ result: activation.result,
190
+ dispose: () => Promise.resolve(activation.dispose?.()).catch(() => {}),
191
+ }
192
+ }
193
+
194
+ // Rendering and scoring live in lib/score-lane.js so the integration test
195
+ // drives the same path the plugin does.
196
+ const scoreLane = makeScoreLane(runCommand)
197
+
198
+ /**
199
+ * Steer a running builder: one user-authored message to the exact child.
200
+ * Failures are logged and ignored — a nudge is an optimisation, never a
201
+ * precondition for the lane to finish.
202
+ */
203
+ const nudgeLane = async ({ childId, parent, text }) => {
204
+ if (childId === null || parent === undefined) return
205
+ await ctx.subagents.sendMessage(parent, childId, [{ type: 'text', text }], {
206
+ signal: new AbortController().signal,
207
+ })
208
+ }
209
+
210
+ const engine = new CloneEngine({
211
+ now: () => Date.now(),
212
+ id: () => (globalThis.crypto?.randomUUID?.() ?? `${Date.now()}${Math.random()}`),
213
+ fs: { cp, copyFile, mkdir, readFile, readdir, realpath, rm, stat, symlink, writeFile },
214
+ git: (args, cwd) => runCommand(['git', ...args], cwd, { timeoutMs: 120_000 }),
215
+ spawnAgent,
216
+ interruptAgent: ({ childId, parent }) => {
217
+ if (childId === null) return
218
+ ctx.subagents.interrupt(childId, { kind: 'ancestor', agent: parent })
219
+ Promise.resolve(activations.get(childId)?.dispose?.()).catch(() => {})
220
+ activations.delete(childId)
221
+ },
222
+ getAgent: (childId) => ctx.agents.get(childId),
223
+ resolveReference,
224
+ scoreLane,
225
+ nudgeLane,
226
+ workingDirectory: ctx.workingDirectory,
227
+ logger: { warn: (message) => log('warn', message) },
228
+ config,
229
+ })
230
+
231
+ // ── text rendering shared by the command and the tool ────────────────────
232
+
233
+ const renderStatus = (clone) => {
234
+ if (clone === null || clone === undefined) {
235
+ return 'No clone yet. Paste a screenshot, then run /clone 3 — or /clone 3 ./mock.png'
236
+ }
237
+ const lines = [
238
+ `Clone ${clone.id} · ${clone.status}${clone.kind === 'refine' ? ' · refine' : ''}${clone.note.length > 0 ? ` · "${truncate(clone.note, 60)}"` : ''}`,
239
+ `Reference: ${clone.reference.label} · viewport ${clone.viewport}${clone.restored === true ? ' · restored after a restart' : ''}`,
240
+ ]
241
+ if (clone.kind === 'refine' && typeof clone.sourceScore === 'number') {
242
+ const delta = typeof clone.delta === 'number' ? clone.delta : null
243
+ lines.push(`Refined from ${clone.sourceId} (was ${clone.sourceScore}%)${delta === null ? '' : ` → ${delta >= 0 ? '+' : ''}${delta} points`}`)
244
+ }
245
+ for (const lane of clone.lanes) {
246
+ const badge = lane.status === 'settled' ? 'ready' : lane.status
247
+ const size = lane.files.length === 0 ? 'no files' : `${lane.files.length} files`
248
+ const suggested = lane.index === clone.winner ? ' · suggested' : ''
249
+ const score = typeof lane.score === 'number' ? ` · ${lane.score}% match` : ''
250
+ lines.push(` lane ${lane.index + 1} (${lane.strategy.label}): ${badge} · ${lane.tools} tools · ${size}${score}${suggested}${lane.error === null ? '' : ` · ${lane.error}`}`)
251
+ }
252
+ if (clone.picked !== null) lines.push(`Adopted into: ${clone.picked.path}`)
253
+ else if (clone.status === 'finished') lines.push('Adopt one with: /clone pick <lane>')
254
+ if (clone.status === 'finished' && clone.lanes.every((lane) => typeof lane.score !== 'number')) {
255
+ lines.push('No browser was found for scoring, so lanes are ranked by completeness and finish time.')
256
+ }
257
+ return lines.join('\n')
258
+ }
259
+
260
+ const startClone = async ({ agent, sessionId, lanes, note }) => {
261
+ const clone = await engine.start({
262
+ agent,
263
+ sessionId,
264
+ lanes,
265
+ note,
266
+ viewport: config.viewport,
267
+ })
268
+ const ready = clone.lanes.filter((lane) => lane.status === 'running').length
269
+ return [
270
+ `Clone ${clone.id} started: ${clone.lanes.length} lanes rebuilding ${clone.reference.label} in parallel.`,
271
+ `Each lane works in its own directory. Open the Clone tab in the conversation to watch them side by side.`,
272
+ ready === 0 ? 'No lane started — check the plugin log.' : `${ready} builder agents are running.`,
273
+ ].join('\n')
274
+ }
275
+
276
+ const latestClone = (sessionId) => engine.latestForSession(sessionId)
277
+
278
+ /**
279
+ * Find a clone by id, falling back to what is on disk.
280
+ *
281
+ * The engine's memory does not survive a plugin reload; the workspace does.
282
+ * Restoring lazily keeps an expensive finished run adoptable afterwards.
283
+ */
284
+ const findClone = async (cloneId, agent) => {
285
+ const known = cloneId === undefined ? undefined : engine.get(cloneId)
286
+ if (known !== undefined) return known
287
+ try {
288
+ await engine.restore(await ctx.workingDirectory.ensure(agent))
289
+ } catch (error) {
290
+ log('warn', `could not restore clones: ${reasonOf(error)}`)
291
+ }
292
+ return cloneId === undefined ? engine.latestForSession(agent.id) : engine.get(cloneId)
293
+ }
294
+
295
+ const perform = async ({ action, cloneId, lane, agent, sessionId, lanes, note }) => {
296
+ switch (action) {
297
+ case 'start': {
298
+ const text = await startClone({ agent, sessionId, lanes, note })
299
+ return { text, clone: latestClone(sessionId) }
300
+ }
301
+ case 'status': {
302
+ // `clone_id` addresses an older clone, which the panel cannot reach once
303
+ // a newer one exists; without it the newest clone for this session wins.
304
+ const target = cloneId === undefined ? latestClone(sessionId) ?? await findClone(undefined, agent) : await findClone(cloneId, agent)
305
+ if (cloneId !== undefined && (target === undefined || target === null)) {
306
+ throw new Error(`dsh-clone: unknown clone "${cloneId}"`)
307
+ }
308
+ return { text: renderStatus(target ?? null), clone: target ?? null }
309
+ }
310
+ case 'refine': {
311
+ const source = cloneId === undefined ? await findClone(undefined, agent) : await findClone(cloneId, agent)
312
+ if (source === undefined || source === null) throw new Error('dsh-clone: no clone to refine')
313
+ const refined = await engine.refine({
314
+ cloneId: source.id,
315
+ laneNumber: lane ?? null,
316
+ agent,
317
+ change: note ?? '',
318
+ viewport: undefined,
319
+ })
320
+ const base = typeof refined.sourceScore === 'number' ? ` Previously ${refined.sourceScore}%.` : ''
321
+ return {
322
+ text: [
323
+ `Refining lane ${(refined.lanes[0]?.index ?? 0) + 1} of clone ${source.id} into ${refined.id}.${base}`,
324
+ 'The builder edits the page it already wrote instead of starting over; the result is scored against the same reference.',
325
+ 'Watch it in the Clone tab, then adopt with /clone pick 1.',
326
+ ].join(' '),
327
+ clone: refined,
328
+ }
329
+ }
330
+ case 'pick': {
331
+ const target = cloneId === undefined ? latestClone(sessionId) : engine.get(cloneId)
332
+ if (target === null || target === undefined) throw new Error('dsh-clone: no clone to adopt')
333
+ const picked = await engine.pick({ cloneId: target.id, laneNumber: lane ?? null })
334
+ return {
335
+ text: `Lane ${picked.lane.index + 1} adopted into ${picked.path}. Open index.html to see it; the other lanes stay in .dsh-clone/ until you /clone discard.`,
336
+ clone: target,
337
+ }
338
+ }
339
+ case 'cancel': {
340
+ const target = cloneId === undefined ? latestClone(sessionId) : engine.get(cloneId)
341
+ if (target === null || target === undefined) throw new Error('dsh-clone: no clone to cancel')
342
+ const clone = await engine.cancel({ cloneId: target.id })
343
+ return { text: `Clone ${clone.id} cancelled.`, clone }
344
+ }
345
+ case 'discard': {
346
+ const target = cloneId === undefined ? latestClone(sessionId) : engine.get(cloneId)
347
+ if (target === null || target === undefined) throw new Error('dsh-clone: no clone to discard')
348
+ const clone = await engine.discard({ cloneId: target.id })
349
+ return { text: `Clone ${clone.id} scratch directories removed.`, clone }
350
+ }
351
+ default:
352
+ throw new Error(`dsh-clone: unknown action "${String(action)}"`)
353
+ }
354
+ }
355
+
356
+ // ── /clone ──────────────────────────────────────────────────────────────
357
+
358
+ if (config.command) {
359
+ ctx.effect(() => ctx.commands.register({
360
+ name: config.commandName,
361
+ description: 'Rebuild a reference screenshot as working UI: /clone [lanes] [note] | status | pick <n> | cancel | discard',
362
+ input: { hint: '[lanes] [note]', attachments: true },
363
+ async handler(invocation) {
364
+ try {
365
+ const parsed = parseCloneArgs(invocation.rawInput, { defaultLanes: config.lanes, maxLanes: config.maxLanes })
366
+ if (parsed.kind === 'help') {
367
+ return {
368
+ kind: 'success',
369
+ text: [
370
+ '/clone [lanes] [note] — rebuild the last image in this conversation (or a path in the note) using N lanes (1-4)',
371
+ '/clone status — lane states of the latest clone',
372
+ '/clone pick <lane> — copy that lane into the workspace as a working app',
373
+ '/clone refine [lane] [change] — rebuild the adopted page with one change, scored against the same reference',
374
+ '/clone cancel — stop the builders',
375
+ '/clone discard — delete the scratch directories',
376
+ ].join('\n'),
377
+ }
378
+ }
379
+ if (parsed.kind === 'status') {
380
+ return { kind: 'success', text: renderStatus(latestClone(invocation.agent.id)) }
381
+ }
382
+ const result = await perform({
383
+ action: parsed.kind,
384
+ lane: parsed.lane ?? null,
385
+ note: parsed.note ?? '',
386
+ agent: invocation.agent,
387
+ sessionId: invocation.agent.id,
388
+ })
389
+ return { kind: 'success', text: result.text }
390
+ } catch (error) {
391
+ return { kind: 'error', text: reasonOf(error) }
392
+ }
393
+ },
394
+ }), 'dsh-clone: /clone command')
395
+ }
396
+
397
+ // ── clone tool ──────────────────────────────────────────────────────────
398
+
399
+ if (config.tool) {
400
+ ctx.effect(() => ctx.tools.register({
401
+ name: 'clone',
402
+ description: [
403
+ 'Rebuild a user interface from a reference screenshot, in parallel, and adopt the result the user prefers.',
404
+ 'Use `start` when the user shows a screenshot and wants it as working code: it launches N builder agents, each in its own isolated directory (a git worktree inside a repository), all rebuilding the same reference image.',
405
+ 'The operations mirror the /clone command: start, status, pick, refine, cancel, discard. `pick` copies one finished lane into the workspace as a working static page; `refine` rebuilds an adopted page with one requested change and scores it against the same reference, so the improvement is measurable.',
406
+ ].join(' '),
407
+ parameters: {
408
+ type: 'object',
409
+ additionalProperties: false,
410
+ required: ['action'],
411
+ properties: {
412
+ action: {
413
+ type: 'string',
414
+ enum: ['start', 'status', 'pick', 'refine', 'cancel', 'discard'],
415
+ description: 'start a clone, read its state, adopt one lane, stop the builders, or delete the scratch directories.',
416
+ },
417
+ lanes: {
418
+ type: 'number',
419
+ description: 'Number of parallel builder lanes for `start` (1-4). Defaults to the configured value.',
420
+ },
421
+ note: {
422
+ type: 'string',
423
+ description: 'Extra instruction for the builders, and optionally a reference image path. Omitted means the last image in the conversation is the reference.',
424
+ },
425
+ lane: {
426
+ type: 'number',
427
+ description: '1-based lane number for `pick` or `refine`. Omitted uses the suggested lane.',
428
+ },
429
+ clone_id: {
430
+ type: 'string',
431
+ description: 'Target an older clone instead of the latest one for this session.',
432
+ },
433
+ },
434
+ },
435
+ output: {
436
+ schema: { type: 'string' },
437
+ render: (_args, value) => [{ type: 'text', text: String(value) }],
438
+ },
439
+ async execute(args, exec) {
440
+ const agent = exec.agent
441
+ if (agent === undefined) throw new Error('dsh-clone: the clone tool needs a calling agent')
442
+ const result = await perform({
443
+ action: args.action,
444
+ cloneId: args.clone_id,
445
+ lane: args.lane ?? null,
446
+ agent,
447
+ sessionId: agent.id,
448
+ lanes: args.lanes ?? config.lanes,
449
+ note: args.note ?? '',
450
+ })
451
+ return result.text
452
+ },
453
+ }), 'dsh-clone: clone tool')
454
+ }
455
+
456
+ // ── live counters ───────────────────────────────────────────────────────
457
+
458
+ ctx.effect(() => {
459
+ const timer = setInterval(() => {
460
+ try {
461
+ engine.tick()
462
+ } catch (error) {
463
+ log('warn', `tick failed: ${reasonOf(error)}`)
464
+ }
465
+ }, config.pollMs)
466
+ return () => clearInterval(timer)
467
+ }, 'dsh-clone: lane counter poller')
468
+
469
+ // ── Web routes ──────────────────────────────────────────────────────────
470
+
471
+ ctx.inject(['webServer'], (scope) => {
472
+ scope.effect(() => {
473
+ const reject = (request, response) => {
474
+ const connection = ctx.get('connection')
475
+ const rejection = connection?.requestRejection?.(request)
476
+ if (rejection === undefined) return false
477
+ response.statusCode = rejection
478
+ response.end()
479
+ return true
480
+ }
481
+
482
+ const state = scope.webServer.register({
483
+ kind: 'exact',
484
+ path: '/api/dsh-clone/state',
485
+ handler: (request, response) => {
486
+ if (reject(request, response)) return
487
+ const url = new URL(request.url ?? '/', 'http://127.0.0.1')
488
+ const sessionId = url.searchParams.get('sessionId') ?? undefined
489
+ const cloneId = url.searchParams.get('cloneId')
490
+ if (cloneId !== null) {
491
+ const clone = engine.get(cloneId)
492
+ sendJson(response, 200, { clones: clone === undefined ? [] : [engine.snapshot(clone)] })
493
+ return
494
+ }
495
+ const clones = engine.listForSession(sessionId).slice(0, 8).map((clone) => engine.snapshot(clone))
496
+ sendJson(response, 200, { clones })
497
+ },
498
+ })
499
+
500
+ const reference = scope.webServer.register({
501
+ kind: 'exact',
502
+ path: '/api/dsh-clone/reference',
503
+ handler: async (request, response) => {
504
+ if (reject(request, response)) return
505
+ const url = new URL(request.url ?? '/', 'http://127.0.0.1')
506
+ const clone = engine.get(url.searchParams.get('cloneId') ?? '')
507
+ if (clone === undefined) {
508
+ sendText(response, 404, 'not found')
509
+ return
510
+ }
511
+ await serveImageFile({
512
+ fs: { readFile },
513
+ path: clone.reference.path,
514
+ mediaType: clone.reference.mediaType,
515
+ response,
516
+ })
517
+ },
518
+ })
519
+
520
+ const render = scope.webServer.register({
521
+ kind: 'exact',
522
+ path: '/api/dsh-clone/render',
523
+ handler: async (request, response) => {
524
+ if (reject(request, response)) return
525
+ const url = new URL(request.url ?? '/', 'http://127.0.0.1')
526
+ const clone = engine.get(url.searchParams.get('cloneId') ?? '')
527
+ const lane = clone?.lanes?.[Number(url.searchParams.get('lane')) - 1]
528
+ if (clone === undefined || lane === undefined || typeof lane.renderPath !== 'string') {
529
+ sendText(response, 404, 'not found')
530
+ return
531
+ }
532
+ await serveImageFile({ fs: { readFile }, path: lane.renderPath, mediaType: 'image/png', response })
533
+ },
534
+ })
535
+
536
+ const preview = scope.webServer.register({
537
+ kind: 'prefix',
538
+ path: '/api/dsh-clone/preview/',
539
+ handler: async (request, response) => {
540
+ if (reject(request, response)) return
541
+ const url = new URL(request.url ?? '/', 'http://127.0.0.1')
542
+ const parsed = parsePreviewPath(url.pathname)
543
+ if (parsed === null) {
544
+ sendText(response, 404, 'not found')
545
+ return
546
+ }
547
+ const clone = engine.get(parsed.cloneId)
548
+ const lane = clone?.lanes?.[parsed.lane - 1]
549
+ if (clone === undefined || lane === undefined) {
550
+ sendText(response, 404, 'not found')
551
+ return
552
+ }
553
+ await serveLaneFile({ fs: { readFile, stat }, root: lane.dir, relativePath: parsed.rest, response })
554
+ },
555
+ })
556
+
557
+ const action = scope.webServer.register({
558
+ kind: 'exact',
559
+ path: '/api/dsh-clone/action',
560
+ handler: async (request, response) => {
561
+ if (reject(request, response)) return
562
+ if (request.method !== 'POST') {
563
+ sendText(response, 405, 'method not allowed')
564
+ return
565
+ }
566
+ try {
567
+ const body = await readJsonBody(request)
568
+ const cloneId = typeof body.cloneId === 'string' ? body.cloneId : undefined
569
+ const target = cloneId === undefined ? null : engine.get(cloneId)
570
+ if (cloneId !== undefined && target === undefined) {
571
+ sendJson(response, 404, { ok: false, error: `unknown clone "${cloneId}"` })
572
+ return
573
+ }
574
+ const clone = target ?? engine.listForSession(undefined)[0]
575
+ if (clone === undefined || clone === null) {
576
+ sendJson(response, 404, { ok: false, error: 'no clone available' })
577
+ return
578
+ }
579
+ switch (body.action) {
580
+ case 'pick': {
581
+ const laneNumber = Number.isInteger(body.lane) ? body.lane : null
582
+ const picked = await engine.pick({ cloneId: clone.id, laneNumber })
583
+ sendJson(response, 200, { ok: true, path: picked.path, lane: picked.lane.index, clone: engine.snapshot(clone) })
584
+ return
585
+ }
586
+ case 'cancel':
587
+ await engine.cancel({ cloneId: clone.id })
588
+ sendJson(response, 200, { ok: true, clone: engine.snapshot(clone) })
589
+ return
590
+ case 'discard':
591
+ await engine.discard({ cloneId: clone.id })
592
+ sendJson(response, 200, { ok: true, clone: engine.snapshot(clone) })
593
+ return
594
+ default:
595
+ sendJson(response, 400, { ok: false, error: `unknown action "${String(body.action)}"` })
596
+ }
597
+ } catch (error) {
598
+ sendJson(response, 500, { ok: false, error: reasonOf(error) })
599
+ }
600
+ },
601
+ })
602
+
603
+ return () => {
604
+ for (const dispose of [state, reference, render, preview, action]) {
605
+ try {
606
+ dispose()
607
+ } catch {
608
+ /* one route must not strand the others */
609
+ }
610
+ }
611
+ }
612
+ }, 'dsh-clone: web routes')
613
+ })
614
+
615
+ // A plugin reload must not leave builder agents spending tokens for a result
616
+ // nobody can reach; their lanes keep whatever they wrote, on disk.
617
+ ctx.effect(() => () => {
618
+ try {
619
+ engine.dispose()
620
+ } catch (error) {
621
+ log('warn', `dispose failed: ${reasonOf(error)}`)
622
+ }
623
+ }, 'dsh-clone: stop builders on unload')
624
+
625
+ log('info', `mounted (lanes=${config.lanes}, viewport=${config.viewport}, command=/${config.commandName})`)
626
+ }