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