opencode-drive 0.1.7 → 0.1.9
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/README.md +3 -1
- package/package.json +9 -3
- package/src/cli/commands.ts +5 -9
- package/src/cli/control.ts +55 -11
- package/src/cli/index.ts +5 -34
- package/src/cli/instance.ts +74 -52
- package/src/cli/media.ts +16 -0
- package/src/cli/restart.ts +2 -2
- package/src/cli/script.ts +33 -8
- package/src/cli/send.ts +4 -6
- package/src/cli/start.ts +119 -24
- package/src/cli/stop.ts +11 -4
- package/src/cli/types.ts +1 -1
- package/src/client/client.ts +33 -18
- package/src/client/protocol.ts +13 -11
- package/src/experimental/reproduce-stale-exploring-empty.ts +102 -0
- package/src/index.ts +6 -1
- package/src/recording/decode.ts +93 -0
- package/src/recording/export.ts +113 -0
- package/src/recording/index.ts +13 -0
- package/src/recording/render.ts +80 -0
- package/src/recording/replay.ts +55 -0
- package/src/recording/terminal.ts +124 -0
- package/src/recording/types.ts +50 -0
- package/src/experimental/reproduce-stale-running-visible.ts +0 -60
- package/src/experimental/reproduce-stale-running.ts +0 -26
- package/src/experimental/stale-running-driver.ts +0 -106
package/src/cli/start.ts
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import { launchInstance } from "./instance.js"
|
|
2
2
|
import { mkdir, rm } from "node:fs/promises"
|
|
3
3
|
import { join } from "node:path"
|
|
4
|
+
import { connectSimulation } from "../client/index.js"
|
|
5
|
+
import { exportRecording } from "../recording/index.js"
|
|
4
6
|
import { connectMockBackend } from "./mock-backend.js"
|
|
5
7
|
import { createResponseSettings } from "./response-generator.js"
|
|
6
|
-
import { runScript } from "./script.js"
|
|
8
|
+
import { loadScript, runScript } from "./script.js"
|
|
9
|
+
import type { DriveScript } from "./script.js"
|
|
7
10
|
import { listenControl } from "./control.js"
|
|
8
11
|
import {
|
|
9
12
|
controlPath,
|
|
@@ -19,14 +22,16 @@ import type { StartOptions } from "./types.js"
|
|
|
19
22
|
export async function start(options: StartOptions) {
|
|
20
23
|
if (!options.visible && !options.script && !options.daemon)
|
|
21
24
|
return startDetached(options)
|
|
25
|
+
const script = options.script ? await loadScript(options.script) : undefined
|
|
22
26
|
const responses = createResponseSettings()
|
|
23
27
|
const instance = await launchInstance({
|
|
24
28
|
name: options.name,
|
|
25
29
|
command: options.command,
|
|
26
30
|
dev: options.dev,
|
|
27
|
-
state: options.state,
|
|
28
31
|
scripted: options.script !== undefined,
|
|
29
32
|
visible: options.visible,
|
|
33
|
+
record: options.record,
|
|
34
|
+
setup: script?.setup,
|
|
30
35
|
})
|
|
31
36
|
await register({
|
|
32
37
|
version: 1,
|
|
@@ -43,13 +48,48 @@ export async function start(options: StartOptions) {
|
|
|
43
48
|
await instance.stop()
|
|
44
49
|
throw error
|
|
45
50
|
})
|
|
46
|
-
const interrupt = () => void instance.stop()
|
|
47
51
|
let completed = false
|
|
48
52
|
let current: ReturnType<typeof run> | undefined
|
|
49
|
-
let restarting: Promise<
|
|
53
|
+
let restarting: Promise<string | undefined> | undefined
|
|
50
54
|
let stopping = false
|
|
55
|
+
const screenshots: string[] = []
|
|
56
|
+
let driveReady = false
|
|
57
|
+
let recording: Promise<string | undefined> | undefined
|
|
58
|
+
const finishCurrentRecording = (onProgress?: (percent: number) => void) => {
|
|
59
|
+
if (!options.record || options.visible || !driveReady)
|
|
60
|
+
return Promise.resolve(undefined)
|
|
61
|
+
recording ??= finishRecording(instance, onProgress)
|
|
62
|
+
return recording
|
|
63
|
+
}
|
|
64
|
+
const interrupt = () => {
|
|
65
|
+
stopping = true
|
|
66
|
+
current?.abort.abort(new Error("opencode-drive interrupted"))
|
|
67
|
+
void finishCurrentRecording()
|
|
68
|
+
.catch((error) =>
|
|
69
|
+
process.stderr.write(`opencode-drive: failed to export recording: ${error}\n`),
|
|
70
|
+
)
|
|
71
|
+
.finally(() => instance.stop())
|
|
72
|
+
}
|
|
51
73
|
process.once("SIGINT", interrupt)
|
|
52
74
|
process.once("SIGTERM", interrupt)
|
|
75
|
+
const stopInstance = async (onProgress?: (percent: number) => void) => {
|
|
76
|
+
try {
|
|
77
|
+
const output = await finishCurrentRecording(onProgress)
|
|
78
|
+
return {
|
|
79
|
+
...(output ? { recording: output } : {}),
|
|
80
|
+
screenshots: [...screenshots],
|
|
81
|
+
}
|
|
82
|
+
} finally {
|
|
83
|
+
stopping = true
|
|
84
|
+
current?.abort.abort(new Error("opencode-drive stopped"))
|
|
85
|
+
await instance.stop()
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
const completeScript = async () => {
|
|
89
|
+
completed = true
|
|
90
|
+
const result = await stopInstance()
|
|
91
|
+
for (const screenshot of result.screenshots) console.log(screenshot)
|
|
92
|
+
}
|
|
53
93
|
let closeControl: (() => Promise<void>) | undefined
|
|
54
94
|
try {
|
|
55
95
|
closeControl = await listenControl(controlPath(options.name), {
|
|
@@ -57,34 +97,49 @@ export async function start(options: StartOptions) {
|
|
|
57
97
|
if (restarting) return restarting
|
|
58
98
|
restarting = (async () => {
|
|
59
99
|
await markStarting(options.name, process.pid)
|
|
100
|
+
const output = await finishCurrentRecording()
|
|
60
101
|
const previous = current
|
|
61
102
|
previous?.abort.abort(new Error("script restarted"))
|
|
62
103
|
await previous?.promise.catch(() => undefined)
|
|
104
|
+
driveReady = false
|
|
63
105
|
await instance.restart()
|
|
64
|
-
|
|
106
|
+
recording = undefined
|
|
107
|
+
current = run(options, instance, responses, script?.run, (path) =>
|
|
108
|
+
screenshots.push(path),
|
|
109
|
+
)
|
|
65
110
|
await current.ready
|
|
111
|
+
driveReady = true
|
|
66
112
|
await markReady(options.name, process.pid)
|
|
113
|
+
return output
|
|
67
114
|
})().finally(() => {
|
|
68
115
|
restarting = undefined
|
|
69
116
|
})
|
|
70
117
|
return restarting
|
|
71
118
|
},
|
|
72
|
-
stop:
|
|
73
|
-
stopping = true
|
|
74
|
-
current?.abort.abort(new Error("opencode-drive stopped"))
|
|
75
|
-
await instance.stop()
|
|
76
|
-
},
|
|
119
|
+
stop: stopInstance,
|
|
77
120
|
responses: async (input) => {
|
|
78
121
|
if (options.script)
|
|
79
122
|
throw new Error("responses are unavailable when --script owns the simulation backend")
|
|
80
123
|
return responses.update(input)
|
|
81
124
|
},
|
|
82
125
|
})
|
|
83
|
-
current = run(options, instance, responses)
|
|
126
|
+
current = run(options, instance, responses, script?.run, (path) =>
|
|
127
|
+
screenshots.push(path),
|
|
128
|
+
)
|
|
84
129
|
await current.ready
|
|
130
|
+
driveReady = true
|
|
85
131
|
await markReady(options.name, process.pid)
|
|
86
132
|
if (options.visible) {
|
|
87
|
-
const
|
|
133
|
+
const result = options.script
|
|
134
|
+
? await Promise.race([
|
|
135
|
+
current.promise.then(() => ({ script: true as const })),
|
|
136
|
+
instance.wait().then((status) => ({ script: false as const, status })),
|
|
137
|
+
])
|
|
138
|
+
: { script: false as const, status: await instance.wait() }
|
|
139
|
+
if (result.script) {
|
|
140
|
+
await completeScript()
|
|
141
|
+
}
|
|
142
|
+
const status = result.script ? await instance.wait() : result.status
|
|
88
143
|
if (status !== 0 && !stopping) process.exitCode = status
|
|
89
144
|
return
|
|
90
145
|
}
|
|
@@ -97,6 +152,10 @@ export async function start(options: StartOptions) {
|
|
|
97
152
|
continue
|
|
98
153
|
}
|
|
99
154
|
if (active !== current) continue
|
|
155
|
+
if (options.script) {
|
|
156
|
+
await completeScript()
|
|
157
|
+
break
|
|
158
|
+
}
|
|
100
159
|
completed = true
|
|
101
160
|
break
|
|
102
161
|
}
|
|
@@ -104,14 +163,45 @@ export async function start(options: StartOptions) {
|
|
|
104
163
|
process.off("SIGINT", interrupt)
|
|
105
164
|
process.off("SIGTERM", interrupt)
|
|
106
165
|
current?.abort.abort(new Error("opencode-drive stopped"))
|
|
166
|
+
const recordingPath = await finishCurrentRecording().catch((error) => {
|
|
167
|
+
process.stderr.write(`opencode-drive: failed to export recording: ${error}\n`)
|
|
168
|
+
return undefined
|
|
169
|
+
})
|
|
107
170
|
await closeControl?.()
|
|
108
171
|
await instance.stop()
|
|
109
172
|
await unregister(options.name, process.pid)
|
|
110
173
|
if (options.script && !options.visible)
|
|
111
174
|
report(instance, completed ? "completed" : undefined)
|
|
175
|
+
if (options.script && recordingPath)
|
|
176
|
+
console.error(`opencode-drive: recording ${recordingPath}`)
|
|
112
177
|
}
|
|
113
178
|
}
|
|
114
179
|
|
|
180
|
+
async function finishRecording(
|
|
181
|
+
instance: Awaited<ReturnType<typeof launchInstance>>,
|
|
182
|
+
onProgress?: (percent: number) => void,
|
|
183
|
+
) {
|
|
184
|
+
const expected = instance.recording
|
|
185
|
+
if (!expected) throw new Error("recording was not enabled for this instance")
|
|
186
|
+
let timeline: string
|
|
187
|
+
if (instance.child.exitCode !== null) {
|
|
188
|
+
timeline = expected.timeline
|
|
189
|
+
} else {
|
|
190
|
+
const ui = await connectSimulation({ url: instance.endpoints.ui, timeout: 60_000 })
|
|
191
|
+
try {
|
|
192
|
+
timeline = await ui.finishRecording()
|
|
193
|
+
} finally {
|
|
194
|
+
ui.close()
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
if (timeline !== expected.timeline)
|
|
198
|
+
throw new Error(`OpenCode returned an unexpected recording path: ${timeline}`)
|
|
199
|
+
if (!(await Bun.file(timeline).exists()))
|
|
200
|
+
throw new Error(`OpenCode recording timeline was not created: ${timeline}`)
|
|
201
|
+
await exportRecording(timeline, expected.video, { onProgress })
|
|
202
|
+
return expected.video
|
|
203
|
+
}
|
|
204
|
+
|
|
115
205
|
async function startDetached(options: StartOptions) {
|
|
116
206
|
const existing = await resolveInstance(options.name, { ready: false }).catch(() => undefined)
|
|
117
207
|
if (existing)
|
|
@@ -129,7 +219,7 @@ async function startDetached(options: StartOptions) {
|
|
|
129
219
|
options.name,
|
|
130
220
|
...(options.script ? ["--script", options.script] : []),
|
|
131
221
|
...(options.dev ? ["--dev", options.dev] : []),
|
|
132
|
-
...(options.
|
|
222
|
+
...(options.record ? ["--record"] : []),
|
|
133
223
|
...(options.command.length ? ["--", ...options.command] : []),
|
|
134
224
|
],
|
|
135
225
|
{
|
|
@@ -176,6 +266,8 @@ function run(
|
|
|
176
266
|
options: StartOptions,
|
|
177
267
|
instance: Awaited<ReturnType<typeof launchInstance>>,
|
|
178
268
|
responses: ReturnType<typeof createResponseSettings>,
|
|
269
|
+
driveScript: DriveScript | undefined,
|
|
270
|
+
onScreenshot: (path: string) => void,
|
|
179
271
|
) {
|
|
180
272
|
const abort = new AbortController()
|
|
181
273
|
const child = instance.child
|
|
@@ -188,24 +280,27 @@ function run(
|
|
|
188
280
|
ready: readiness,
|
|
189
281
|
promise: (async () => {
|
|
190
282
|
await instance.waitForDrive("both")
|
|
191
|
-
if (
|
|
283
|
+
if (driveScript) {
|
|
192
284
|
const script = runScript(
|
|
193
|
-
|
|
285
|
+
driveScript,
|
|
194
286
|
instance.artifacts,
|
|
195
287
|
instance.endpoints,
|
|
196
288
|
abort.signal,
|
|
289
|
+
onScreenshot,
|
|
197
290
|
)
|
|
198
291
|
ready()
|
|
199
|
-
await script
|
|
200
292
|
if (options.visible) {
|
|
201
|
-
await
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
293
|
+
await script
|
|
294
|
+
return
|
|
295
|
+
}
|
|
296
|
+
const result = await Promise.race([
|
|
297
|
+
script.then(() => ({ script: true as const })),
|
|
298
|
+
child.exited.then((status) => ({ script: false as const, status })),
|
|
299
|
+
])
|
|
300
|
+
if (!result.script) {
|
|
301
|
+
abort.abort(new Error(`OpenCode exited with status ${result.status}`))
|
|
302
|
+
await script.catch(() => undefined)
|
|
303
|
+
if (result.status !== 0) process.exitCode = result.status
|
|
209
304
|
}
|
|
210
305
|
return
|
|
211
306
|
}
|
package/src/cli/stop.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { requestStop } from "./control.js"
|
|
2
2
|
import { manifestPath, resolveInstance } from "./registry.js"
|
|
3
3
|
|
|
4
4
|
export async function stop(name?: string) {
|
|
5
5
|
const manifest = await resolveInstance(name)
|
|
6
|
-
await
|
|
7
|
-
|
|
6
|
+
const result = await requestStop(manifest.control, (percent) => {
|
|
7
|
+
console.error(`Rendering video: ${percent}%`)
|
|
8
|
+
})
|
|
9
|
+
const deadline = Date.now() + 5 * 60_000
|
|
8
10
|
while (Date.now() < deadline) {
|
|
9
11
|
const current: unknown = await Bun.file(manifestPath(manifest.name))
|
|
10
12
|
.json()
|
|
@@ -15,7 +17,12 @@ export async function stop(name?: string) {
|
|
|
15
17
|
!("pid" in current) ||
|
|
16
18
|
current.pid !== manifest.pid
|
|
17
19
|
) {
|
|
18
|
-
console.log(
|
|
20
|
+
for (const screenshot of result.screenshots) console.log(screenshot)
|
|
21
|
+
if (result.recording) {
|
|
22
|
+
console.error(`Video successfully created: ${result.recording}`)
|
|
23
|
+
} else if (result.screenshots.length === 0) {
|
|
24
|
+
console.log("success")
|
|
25
|
+
}
|
|
19
26
|
return
|
|
20
27
|
}
|
|
21
28
|
await Bun.sleep(25)
|
package/src/cli/types.ts
CHANGED
package/src/client/client.ts
CHANGED
|
@@ -4,20 +4,16 @@ const defaultPort = 40900
|
|
|
4
4
|
|
|
5
5
|
type Methods = {
|
|
6
6
|
readonly "ui.screenshot": {
|
|
7
|
-
readonly params: undefined
|
|
7
|
+
readonly params: Frontend.ScreenshotParams | undefined
|
|
8
8
|
readonly result: Frontend.Screenshot
|
|
9
9
|
}
|
|
10
10
|
readonly "ui.state": {
|
|
11
11
|
readonly params: undefined
|
|
12
12
|
readonly result: Frontend.State
|
|
13
13
|
}
|
|
14
|
-
readonly "ui.
|
|
15
|
-
readonly params: undefined
|
|
16
|
-
readonly result: Frontend.StartRecord
|
|
17
|
-
}
|
|
18
|
-
readonly "ui.end-record": {
|
|
14
|
+
readonly "ui.recording.finish": {
|
|
19
15
|
readonly params: undefined
|
|
20
|
-
readonly result: Frontend.
|
|
16
|
+
readonly result: Frontend.RecordingFinish
|
|
21
17
|
}
|
|
22
18
|
readonly "ui.type": {
|
|
23
19
|
readonly params: Frontend.TypeParams
|
|
@@ -69,6 +65,7 @@ export interface SimulationClientOptions {
|
|
|
69
65
|
readonly portAttempts?: number
|
|
70
66
|
/** Per-call timeout in milliseconds. Defaults to 30_000. */
|
|
71
67
|
readonly timeout?: number
|
|
68
|
+
readonly onScreenshot?: (path: string) => void
|
|
72
69
|
}
|
|
73
70
|
|
|
74
71
|
export class SimulationError extends Error {
|
|
@@ -93,13 +90,20 @@ export class SimulationClient {
|
|
|
93
90
|
|
|
94
91
|
private readonly socket: WebSocket
|
|
95
92
|
private readonly timeout: number
|
|
93
|
+
private readonly onScreenshot?: (path: string) => void
|
|
96
94
|
private nextId = 1
|
|
97
95
|
private readonly pending = new Map<number, Waiter>()
|
|
98
96
|
|
|
99
|
-
private constructor(
|
|
97
|
+
private constructor(
|
|
98
|
+
socket: WebSocket,
|
|
99
|
+
url: string,
|
|
100
|
+
timeout: number,
|
|
101
|
+
onScreenshot?: (path: string) => void,
|
|
102
|
+
) {
|
|
100
103
|
this.socket = socket
|
|
101
104
|
this.url = url
|
|
102
105
|
this.timeout = timeout
|
|
106
|
+
this.onScreenshot = onScreenshot
|
|
103
107
|
socket.addEventListener("message", (event) =>
|
|
104
108
|
this.onMessage(String(event.data)),
|
|
105
109
|
)
|
|
@@ -116,14 +120,24 @@ export class SimulationClient {
|
|
|
116
120
|
): Promise<SimulationClient> {
|
|
117
121
|
const timeout = options?.timeout ?? 30_000
|
|
118
122
|
if (options?.url !== undefined) {
|
|
119
|
-
return new SimulationClient(
|
|
123
|
+
return new SimulationClient(
|
|
124
|
+
await open(options.url),
|
|
125
|
+
options.url,
|
|
126
|
+
timeout,
|
|
127
|
+
options.onScreenshot,
|
|
128
|
+
)
|
|
120
129
|
}
|
|
121
130
|
const first = options?.port ?? defaultPort
|
|
122
131
|
const attempts = options?.portAttempts ?? 10
|
|
123
132
|
for (let offset = 0; offset < attempts; offset++) {
|
|
124
133
|
const url = `ws://127.0.0.1:${first + offset}`
|
|
125
134
|
try {
|
|
126
|
-
return new SimulationClient(
|
|
135
|
+
return new SimulationClient(
|
|
136
|
+
await open(url),
|
|
137
|
+
url,
|
|
138
|
+
timeout,
|
|
139
|
+
options?.onScreenshot,
|
|
140
|
+
)
|
|
127
141
|
} catch {
|
|
128
142
|
// occupied by something else or nothing listening; try the next port
|
|
129
143
|
}
|
|
@@ -170,16 +184,17 @@ export class SimulationClient {
|
|
|
170
184
|
return this.call("ui.state")
|
|
171
185
|
}
|
|
172
186
|
|
|
173
|
-
screenshot(): Promise<Frontend.Screenshot> {
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
187
|
+
async screenshot(name?: string): Promise<Frontend.Screenshot> {
|
|
188
|
+
const path = await this.call(
|
|
189
|
+
"ui.screenshot",
|
|
190
|
+
name === undefined ? undefined : { name },
|
|
191
|
+
)
|
|
192
|
+
this.onScreenshot?.(path)
|
|
193
|
+
return path
|
|
179
194
|
}
|
|
180
195
|
|
|
181
|
-
|
|
182
|
-
return this.call("ui.
|
|
196
|
+
finishRecording(): Promise<Frontend.RecordingFinish> {
|
|
197
|
+
return this.call("ui.recording.finish")
|
|
183
198
|
}
|
|
184
199
|
|
|
185
200
|
/** Executes one user-level action and returns the post-action state. */
|
package/src/client/protocol.ts
CHANGED
|
@@ -111,11 +111,14 @@ export namespace Frontend {
|
|
|
111
111
|
export const Screenshot = Schema.String
|
|
112
112
|
export type Screenshot = Schema.Schema.Type<typeof Screenshot>
|
|
113
113
|
|
|
114
|
-
export const
|
|
115
|
-
export
|
|
114
|
+
export const RecordingFinish = Schema.String
|
|
115
|
+
export type RecordingFinish = Schema.Schema.Type<typeof RecordingFinish>
|
|
116
116
|
|
|
117
|
-
export const
|
|
118
|
-
|
|
117
|
+
export const ScreenshotParams = Schema.Struct({
|
|
118
|
+
name: Schema.optional(Schema.String),
|
|
119
|
+
})
|
|
120
|
+
export interface ScreenshotParams
|
|
121
|
+
extends Schema.Schema.Type<typeof ScreenshotParams> {}
|
|
119
122
|
|
|
120
123
|
export const TypeParams = Schema.Struct({ text: Schema.String })
|
|
121
124
|
export interface TypeParams extends Schema.Schema.Type<typeof TypeParams> {}
|
|
@@ -169,13 +172,12 @@ export namespace Frontend {
|
|
|
169
172
|
}),
|
|
170
173
|
Schema.Struct({
|
|
171
174
|
...JsonRpc.RequestFields,
|
|
172
|
-
method: Schema.
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
]),
|
|
175
|
+
method: Schema.Literal("ui.screenshot"),
|
|
176
|
+
params: Schema.optional(ScreenshotParams),
|
|
177
|
+
}),
|
|
178
|
+
Schema.Struct({
|
|
179
|
+
...JsonRpc.RequestFields,
|
|
180
|
+
method: Schema.Literals(["ui.enter", "ui.state", "ui.recording.finish"]),
|
|
179
181
|
}),
|
|
180
182
|
])
|
|
181
183
|
export type Request = Schema.Schema.Type<typeof Request>
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { join } from "node:path"
|
|
2
|
+
import { defineScript } from "../index.js"
|
|
3
|
+
|
|
4
|
+
export default defineScript(async ({ artifacts, backend, ui }) => {
|
|
5
|
+
const completed = Array.from({ length: 3 }, () => Promise.withResolvers<void>())
|
|
6
|
+
let turn = 0
|
|
7
|
+
|
|
8
|
+
await backend.attach(async (request) => {
|
|
9
|
+
if (isTitleRequest(request.body)) {
|
|
10
|
+
await backend.chunk(request.id, [{ type: "textDelta", text: "Stale exploring reproduction" }])
|
|
11
|
+
await backend.finish(request.id)
|
|
12
|
+
return
|
|
13
|
+
}
|
|
14
|
+
const current = turn++
|
|
15
|
+
if (current === 0) {
|
|
16
|
+
await backend.chunk(request.id, [
|
|
17
|
+
{
|
|
18
|
+
type: "toolCall",
|
|
19
|
+
index: 0,
|
|
20
|
+
id: "call_read",
|
|
21
|
+
name: "read",
|
|
22
|
+
input: { filePath: join(artifacts, "files", "src", "garden.js") },
|
|
23
|
+
},
|
|
24
|
+
])
|
|
25
|
+
await backend.finish(request.id, "tool-calls")
|
|
26
|
+
return
|
|
27
|
+
}
|
|
28
|
+
if (current === 1) {
|
|
29
|
+
await backend.finish(request.id, "tool-calls")
|
|
30
|
+
completed[0]?.resolve()
|
|
31
|
+
return
|
|
32
|
+
}
|
|
33
|
+
const response = current === 2 ? "The file exports a small greeting function." : "Confirmed again with no more tools."
|
|
34
|
+
await backend.chunk(request.id, [{ type: "textDelta", text: response }])
|
|
35
|
+
await backend.finish(request.id)
|
|
36
|
+
completed[current - 1]?.resolve()
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
const prompts = [
|
|
40
|
+
"Read src/garden.js, then tell me what it contains.",
|
|
41
|
+
"Now inspect that file one more time.",
|
|
42
|
+
"Finally, verify the same file again.",
|
|
43
|
+
]
|
|
44
|
+
for (const [index, prompt] of prompts.entries()) {
|
|
45
|
+
if (index === 0) await ui.typeText(prompt)
|
|
46
|
+
else await typeSlowly(ui, prompt)
|
|
47
|
+
await ui.pressEnter()
|
|
48
|
+
await withTimeout(
|
|
49
|
+
completed[index]!.promise,
|
|
50
|
+
30_000,
|
|
51
|
+
`timed out waiting for empty continuation ${index + 1}`,
|
|
52
|
+
)
|
|
53
|
+
await waitForEditor(ui)
|
|
54
|
+
await Bun.sleep(500)
|
|
55
|
+
await ui.screenshot(`stale-exploring-${index + 1}`)
|
|
56
|
+
}
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
async function withTimeout(promise: Promise<void>, timeout: number, message: string) {
|
|
60
|
+
const expired = Promise.withResolvers<never>()
|
|
61
|
+
const timer = setTimeout(() => expired.reject(new Error(message)), timeout)
|
|
62
|
+
try {
|
|
63
|
+
await Promise.race([promise, expired.promise])
|
|
64
|
+
} finally {
|
|
65
|
+
clearTimeout(timer)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function typeSlowly(
|
|
70
|
+
ui: Parameters<Parameters<typeof defineScript>[0]>[0]["ui"],
|
|
71
|
+
text: string,
|
|
72
|
+
) {
|
|
73
|
+
for (const char of text) {
|
|
74
|
+
await ui.typeText(char)
|
|
75
|
+
await Bun.sleep(55)
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function waitForEditor(
|
|
80
|
+
ui: Parameters<Parameters<typeof defineScript>[0]>[0]["ui"],
|
|
81
|
+
) {
|
|
82
|
+
const deadline = Date.now() + 30_000
|
|
83
|
+
while (Date.now() < deadline) {
|
|
84
|
+
if ((await ui.state()).focused.editor) return
|
|
85
|
+
await Bun.sleep(50)
|
|
86
|
+
}
|
|
87
|
+
throw new Error("timed out waiting for the session to become idle")
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function isTitleRequest(body: unknown) {
|
|
91
|
+
if (typeof body !== "object" || body === null || !("messages" in body)) return false
|
|
92
|
+
const messages = body.messages
|
|
93
|
+
if (!Array.isArray(messages)) return false
|
|
94
|
+
return messages.some(
|
|
95
|
+
(message) =>
|
|
96
|
+
typeof message === "object" &&
|
|
97
|
+
message !== null &&
|
|
98
|
+
"content" in message &&
|
|
99
|
+
typeof message.content === "string" &&
|
|
100
|
+
message.content.includes("You are a title generator"),
|
|
101
|
+
)
|
|
102
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
1
1
|
export * from "./client/index.js"
|
|
2
2
|
export { defineScript } from "./cli/script.js"
|
|
3
|
-
export type {
|
|
3
|
+
export type {
|
|
4
|
+
DriveScript,
|
|
5
|
+
DriveScriptSetup,
|
|
6
|
+
ScriptContext,
|
|
7
|
+
ScriptSetupContext,
|
|
8
|
+
} from "./cli/script.js"
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { createReadStream } from "node:fs"
|
|
2
|
+
import type { TimelineHeader, TimelineOutput, TimelineRecord } from "./types.js"
|
|
3
|
+
|
|
4
|
+
function fail(line: number, message: string): never {
|
|
5
|
+
throw new Error(`Invalid recording timeline at line ${line}: ${message}`)
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function isObject(value: unknown): value is Record<string, unknown> {
|
|
9
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function hasExactKeys(value: Record<string, unknown>, expected: string[]) {
|
|
13
|
+
const actual = Object.keys(value).sort()
|
|
14
|
+
return actual.length === expected.length && actual.every((key, index) => key === expected[index])
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function positiveInteger(value: unknown): value is number {
|
|
18
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value > 0
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function nonnegativeInteger(value: unknown): value is number {
|
|
22
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function canonicalBase64(value: string) {
|
|
26
|
+
if (value.length % 4 !== 0 || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)) {
|
|
27
|
+
return false
|
|
28
|
+
}
|
|
29
|
+
return Buffer.from(value, "base64").toString("base64") === value
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function parseRecord(text: string, line: number, first: boolean, previousAt: number): TimelineRecord {
|
|
33
|
+
let value: unknown
|
|
34
|
+
try {
|
|
35
|
+
value = JSON.parse(text)
|
|
36
|
+
} catch {
|
|
37
|
+
fail(line, "line is not valid JSON")
|
|
38
|
+
}
|
|
39
|
+
if (!isObject(value)) fail(line, "record must be an object")
|
|
40
|
+
|
|
41
|
+
if (first) {
|
|
42
|
+
if (!hasExactKeys(value, ["cols", "encoding", "rows", "type", "version"])) fail(line, "invalid header fields")
|
|
43
|
+
if (value.type !== "header" || value.version !== 1 || value.encoding !== "base64") {
|
|
44
|
+
fail(line, "unsupported or missing header")
|
|
45
|
+
}
|
|
46
|
+
if (!positiveInteger(value.cols) || !positiveInteger(value.rows)) fail(line, "cols and rows must be positive integers")
|
|
47
|
+
return { type: "header", version: 1, cols: value.cols, rows: value.rows, encoding: "base64" } satisfies TimelineHeader
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (!hasExactKeys(value, ["at_ms", "data", "type"]) || value.type !== "output") fail(line, "invalid output fields")
|
|
51
|
+
if (!nonnegativeInteger(value.at_ms)) fail(line, "at_ms must be a nonnegative integer")
|
|
52
|
+
if (value.at_ms < previousAt) fail(line, "output timestamps must be nondecreasing")
|
|
53
|
+
if (typeof value.data !== "string" || !canonicalBase64(value.data)) fail(line, "data must be canonical base64")
|
|
54
|
+
return { type: "output", at_ms: value.at_ms, data: value.data } satisfies TimelineOutput
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Decodes and validates a timeline without loading the complete file into memory. */
|
|
58
|
+
export async function* decodeTimeline(path: string): AsyncGenerator<TimelineRecord> {
|
|
59
|
+
const decoder = new TextDecoder("utf-8", { fatal: true })
|
|
60
|
+
let buffered = ""
|
|
61
|
+
let line = 0
|
|
62
|
+
let records = 0
|
|
63
|
+
let previousAt = -1
|
|
64
|
+
|
|
65
|
+
const consume = (raw: string) => {
|
|
66
|
+
line++
|
|
67
|
+
const text = raw.endsWith("\r") ? raw.slice(0, -1) : raw
|
|
68
|
+
if (text.length === 0) fail(line, "empty lines are not allowed")
|
|
69
|
+
const record = parseRecord(text, line, records === 0, previousAt)
|
|
70
|
+
records++
|
|
71
|
+
if (record.type === "output") previousAt = record.at_ms
|
|
72
|
+
return record
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
try {
|
|
76
|
+
for await (const chunk of createReadStream(path)) {
|
|
77
|
+
buffered += decoder.decode(chunk, { stream: true })
|
|
78
|
+
let newline: number
|
|
79
|
+
while ((newline = buffered.indexOf("\n")) !== -1) {
|
|
80
|
+
const raw = buffered.slice(0, newline)
|
|
81
|
+
buffered = buffered.slice(newline + 1)
|
|
82
|
+
yield consume(raw)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
buffered += decoder.decode()
|
|
86
|
+
} catch (error) {
|
|
87
|
+
if (error instanceof TypeError) fail(line + 1, "file is not valid UTF-8")
|
|
88
|
+
throw error
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (buffered.length > 0) yield consume(buffered)
|
|
92
|
+
if (records === 0) fail(1, "missing header")
|
|
93
|
+
}
|