opencode-drive 0.1.7 → 0.1.8
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 -28
- package/src/cli/instance.ts +30 -4
- package/src/cli/media.ts +16 -0
- package/src/cli/restart.ts +2 -2
- package/src/cli/script.ts +2 -1
- package/src/cli/send.ts +4 -6
- package/src/cli/start.ts +108 -19
- package/src/cli/stop.ts +11 -4
- package/src/cli/types.ts +1 -0
- 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/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
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import type { TerminalCore } from "@wterm/core"
|
|
2
|
+
import { GhosttyCore } from "@wterm/ghostty"
|
|
3
|
+
import type { CapturedFrame, CapturedSpan } from "./types.js"
|
|
4
|
+
|
|
5
|
+
const DefaultForeground = 0xd8d8d8
|
|
6
|
+
const DefaultBackground = 0x080808
|
|
7
|
+
const SyncStart = Buffer.from("\x1b[?2026h")
|
|
8
|
+
const SyncEnd = Buffer.from("\x1b[?2026l")
|
|
9
|
+
|
|
10
|
+
export interface TerminalParser {
|
|
11
|
+
write(data: Uint8Array): void
|
|
12
|
+
finish(): void
|
|
13
|
+
resize(cols: number, rows: number): void
|
|
14
|
+
snapshot(): CapturedFrame
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type TerminalParserFactory = (cols: number, rows: number) => Promise<TerminalParser>
|
|
18
|
+
|
|
19
|
+
function characterWidth(char: string) {
|
|
20
|
+
return Math.max(1, Bun.stringWidth(char))
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function capture(core: TerminalCore): CapturedFrame {
|
|
24
|
+
const cols = core.getCols()
|
|
25
|
+
const rows = core.getRows()
|
|
26
|
+
const lines = Array.from({ length: rows }, (_, row) => {
|
|
27
|
+
const spans: CapturedSpan[] = []
|
|
28
|
+
for (let col = 0; col < cols; ) {
|
|
29
|
+
const cell = core.getCell(row, col)
|
|
30
|
+
const text = String.fromCodePoint(cell.char || 32)
|
|
31
|
+
const width = Math.min(characterWidth(text), cols - col)
|
|
32
|
+
const next = {
|
|
33
|
+
text,
|
|
34
|
+
width,
|
|
35
|
+
fg: cell.fgRgb ?? DefaultForeground,
|
|
36
|
+
bg: cell.bgRgb ?? DefaultBackground,
|
|
37
|
+
attributes: cell.flags,
|
|
38
|
+
}
|
|
39
|
+
const previous = spans.at(-1)
|
|
40
|
+
if (
|
|
41
|
+
previous &&
|
|
42
|
+
previous.fg === next.fg &&
|
|
43
|
+
previous.bg === next.bg &&
|
|
44
|
+
previous.attributes === next.attributes
|
|
45
|
+
) {
|
|
46
|
+
previous.text += next.text
|
|
47
|
+
previous.width += width
|
|
48
|
+
} else {
|
|
49
|
+
spans.push(next)
|
|
50
|
+
}
|
|
51
|
+
col += width
|
|
52
|
+
}
|
|
53
|
+
return { spans }
|
|
54
|
+
})
|
|
55
|
+
return { cols, rows, cursor: core.getCursor(), lines }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
class GhosttyTerminal implements TerminalParser {
|
|
59
|
+
private synchronized = false
|
|
60
|
+
private stable?: CapturedFrame
|
|
61
|
+
private pending = Buffer.alloc(0)
|
|
62
|
+
|
|
63
|
+
constructor(private readonly core: TerminalCore) {}
|
|
64
|
+
|
|
65
|
+
write(data: Uint8Array) {
|
|
66
|
+
const input = this.pending.length ? Buffer.concat([this.pending, data]) : Buffer.from(data)
|
|
67
|
+
this.pending = Buffer.alloc(0)
|
|
68
|
+
let offset = 0
|
|
69
|
+
while (offset < input.length) {
|
|
70
|
+
const start = input.indexOf(SyncStart, offset)
|
|
71
|
+
const end = input.indexOf(SyncEnd, offset)
|
|
72
|
+
const marker = start === -1 ? end : end === -1 ? start : Math.min(start, end)
|
|
73
|
+
if (marker === -1) {
|
|
74
|
+
const keep = partialMarkerLength(input.subarray(offset))
|
|
75
|
+
const boundary = input.length - keep
|
|
76
|
+
if (boundary > offset) this.core.writeRaw(input.subarray(offset, boundary))
|
|
77
|
+
if (keep) this.pending = input.subarray(boundary)
|
|
78
|
+
return
|
|
79
|
+
}
|
|
80
|
+
if (marker > offset) this.core.writeRaw(input.subarray(offset, marker))
|
|
81
|
+
if (marker === start) {
|
|
82
|
+
if (!this.synchronized) this.stable = capture(this.core)
|
|
83
|
+
this.synchronized = true
|
|
84
|
+
this.core.writeRaw(SyncStart)
|
|
85
|
+
offset = marker + SyncStart.length
|
|
86
|
+
} else {
|
|
87
|
+
this.core.writeRaw(SyncEnd)
|
|
88
|
+
this.synchronized = false
|
|
89
|
+
this.stable = undefined
|
|
90
|
+
offset = marker + SyncEnd.length
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
finish() {
|
|
96
|
+
if (!this.pending.length) return
|
|
97
|
+
this.core.writeRaw(this.pending)
|
|
98
|
+
this.pending = Buffer.alloc(0)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
resize(cols: number, rows: number) {
|
|
102
|
+
this.core.resize(cols, rows)
|
|
103
|
+
if (this.synchronized) this.stable = capture(this.core)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
snapshot() {
|
|
107
|
+
return this.synchronized && this.stable ? structuredClone(this.stable) : capture(this.core)
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function partialMarkerLength(input: Uint8Array) {
|
|
112
|
+
const limit = Math.min(input.length, Math.max(SyncStart.length, SyncEnd.length) - 1)
|
|
113
|
+
for (let length = limit; length > 0; length--) {
|
|
114
|
+
const suffix = input.subarray(input.length - length)
|
|
115
|
+
if (SyncStart.subarray(0, length).equals(suffix) || SyncEnd.subarray(0, length).equals(suffix)) return length
|
|
116
|
+
}
|
|
117
|
+
return 0
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export const createTerminalParser: TerminalParserFactory = async (cols, rows) => {
|
|
121
|
+
const core: TerminalCore = await GhosttyCore.load()
|
|
122
|
+
core.init(cols, rows)
|
|
123
|
+
return new GhosttyTerminal(core)
|
|
124
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
export const TextStyle = {
|
|
2
|
+
bold: 1,
|
|
3
|
+
dim: 2,
|
|
4
|
+
italic: 4,
|
|
5
|
+
underline: 8,
|
|
6
|
+
blink: 16,
|
|
7
|
+
inverse: 32,
|
|
8
|
+
invisible: 64,
|
|
9
|
+
strikethrough: 128,
|
|
10
|
+
} as const
|
|
11
|
+
|
|
12
|
+
export interface CapturedSpan {
|
|
13
|
+
text: string
|
|
14
|
+
width: number
|
|
15
|
+
fg: number
|
|
16
|
+
bg: number
|
|
17
|
+
attributes: number
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface CapturedLine {
|
|
21
|
+
spans: CapturedSpan[]
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface CapturedFrame {
|
|
25
|
+
cols: number
|
|
26
|
+
rows: number
|
|
27
|
+
cursor: { row: number; col: number; visible: boolean }
|
|
28
|
+
lines: CapturedLine[]
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface SampledFrame {
|
|
32
|
+
atMs: number
|
|
33
|
+
frame: CapturedFrame
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface TimelineHeader {
|
|
37
|
+
type: "header"
|
|
38
|
+
version: 1
|
|
39
|
+
cols: number
|
|
40
|
+
rows: number
|
|
41
|
+
encoding: "base64"
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface TimelineOutput {
|
|
45
|
+
type: "output"
|
|
46
|
+
at_ms: number
|
|
47
|
+
data: string
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export type TimelineRecord = TimelineHeader | TimelineOutput
|
|
@@ -1,60 +0,0 @@
|
|
|
1
|
-
import path from "node:path"
|
|
2
|
-
|
|
3
|
-
const root = "/tmp/opencode-probe-stale-running-visible"
|
|
4
|
-
const state = path.join(root, "state", "files")
|
|
5
|
-
await Bun.$`rm -rf ${root}`.quiet()
|
|
6
|
-
await Bun.$`mkdir -p ${path.join(state, ".config/opencode")} ${path.join(state, "src")}`.quiet()
|
|
7
|
-
|
|
8
|
-
const config = {
|
|
9
|
-
model: "simulation/sim-model",
|
|
10
|
-
permissions: [{ action: "*", resource: "*", effect: "allow" }],
|
|
11
|
-
providers: {
|
|
12
|
-
simulation: {
|
|
13
|
-
name: "Simulation",
|
|
14
|
-
request: { body: { apiKey: "sim-key" } },
|
|
15
|
-
models: {
|
|
16
|
-
"sim-model": {
|
|
17
|
-
name: "Simulated Model",
|
|
18
|
-
api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.openai.com/v1" },
|
|
19
|
-
capabilities: { tools: true, input: ["text"], output: ["text"] },
|
|
20
|
-
limit: { context: 128000, output: 16000 },
|
|
21
|
-
},
|
|
22
|
-
},
|
|
23
|
-
},
|
|
24
|
-
},
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
await Promise.all([
|
|
28
|
-
Bun.write(path.join(state, "opencode.json"), `${JSON.stringify(config, undefined, 2)}\n`),
|
|
29
|
-
Bun.write(path.join(state, ".config/opencode/opencode.json"), `${JSON.stringify(config, undefined, 2)}\n`),
|
|
30
|
-
Bun.write(path.join(state, "src/example.ts"), "export const example = true\n"),
|
|
31
|
-
])
|
|
32
|
-
|
|
33
|
-
const child = Bun.spawn([
|
|
34
|
-
path.resolve("bin/opencode-sim"),
|
|
35
|
-
"--state", path.join(root, "state"),
|
|
36
|
-
"--anchor", path.join(root, "anchor"),
|
|
37
|
-
"--renderer", "visible",
|
|
38
|
-
"--driver", `bun ${path.resolve("src/experimental/stale-running-driver.ts")}`,
|
|
39
|
-
"--",
|
|
40
|
-
"bun", "run", "--conditions=browser", "--preload=@opentui/solid/preload",
|
|
41
|
-
"/root/projects/opencode-latest/packages/cli/src/index.ts",
|
|
42
|
-
], {
|
|
43
|
-
cwd: path.resolve("."),
|
|
44
|
-
env: {
|
|
45
|
-
...processEnv(),
|
|
46
|
-
OPENCODE_SIMULATION_DRIVER_LOG: path.join(root, "driver.log"),
|
|
47
|
-
OPENCODE_SIMULATION_LOG: path.join(root, "simulation.log"),
|
|
48
|
-
},
|
|
49
|
-
stdin: "inherit",
|
|
50
|
-
stdout: "inherit",
|
|
51
|
-
stderr: "inherit",
|
|
52
|
-
})
|
|
53
|
-
|
|
54
|
-
const status = await child.exited
|
|
55
|
-
if (status !== 0) throw new Error(`visible reproduction failed; see ${path.join(root, "driver.log")}`)
|
|
56
|
-
console.log(`Reproduction artifacts: ${root}`)
|
|
57
|
-
|
|
58
|
-
function processEnv(): Record<string, string> {
|
|
59
|
-
return Object.fromEntries(Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined))
|
|
60
|
-
}
|
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
const child = Bun.spawn([
|
|
2
|
-
"bun",
|
|
3
|
-
"test",
|
|
4
|
-
"test/cli/tui/data.test.tsx",
|
|
5
|
-
"--test-name-pattern",
|
|
6
|
-
"clears stale running status after reconnect",
|
|
7
|
-
], {
|
|
8
|
-
cwd: "/root/projects/opencode-latest/packages/tui",
|
|
9
|
-
stdout: "pipe",
|
|
10
|
-
stderr: "pipe",
|
|
11
|
-
})
|
|
12
|
-
|
|
13
|
-
const [status, stdout, stderr] = await Promise.all([
|
|
14
|
-
child.exited,
|
|
15
|
-
new Response(child.stdout).text(),
|
|
16
|
-
new Response(child.stderr).text(),
|
|
17
|
-
])
|
|
18
|
-
const output = `${stdout}${stderr}`
|
|
19
|
-
process.stdout.write(output)
|
|
20
|
-
|
|
21
|
-
if (status !== 0 && output.includes('Expected: "idle"') && output.includes('Received: "running"')) {
|
|
22
|
-
console.log("\nREPRODUCED: reconnect left an inactive session stuck in the running UI state.")
|
|
23
|
-
process.exit(0)
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
throw new Error(`reproduction did not produce the expected stale-running failure (test exit ${status})`)
|
|
@@ -1,106 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
connectBackendSimulation,
|
|
3
|
-
connectSimulation,
|
|
4
|
-
type OpenedExchange,
|
|
5
|
-
} from "../client/index.js"
|
|
6
|
-
import { isRunning } from "./flows/index.js"
|
|
7
|
-
|
|
8
|
-
const ui = await connectSimulation({
|
|
9
|
-
url: requiredEnv("OPENCODE_SIMULATION_UI_WS"),
|
|
10
|
-
})
|
|
11
|
-
const backend = await connectBackendSimulation({
|
|
12
|
-
url: requiredEnv("OPENCODE_SIMULATION_BACKEND_WS"),
|
|
13
|
-
})
|
|
14
|
-
const requestOpened = deferred()
|
|
15
|
-
const releaseResponse = deferred()
|
|
16
|
-
const responseFinished = deferred()
|
|
17
|
-
|
|
18
|
-
await backend.attach(async (request: OpenedExchange) => {
|
|
19
|
-
if (isTitleRequest(request)) {
|
|
20
|
-
await backend.chunk(request.id, [
|
|
21
|
-
{ type: "textDelta", text: "Stale running reproduction" },
|
|
22
|
-
])
|
|
23
|
-
await backend.finish(request.id, "stop")
|
|
24
|
-
return
|
|
25
|
-
}
|
|
26
|
-
requestOpened.resolve()
|
|
27
|
-
await backend.chunk(request.id, [
|
|
28
|
-
{
|
|
29
|
-
type: "textDelta",
|
|
30
|
-
text: "The provider turn is finishing while events are disconnected.",
|
|
31
|
-
},
|
|
32
|
-
])
|
|
33
|
-
await releaseResponse.promise
|
|
34
|
-
await backend.finish(request.id, "stop")
|
|
35
|
-
responseFinished.resolve()
|
|
36
|
-
})
|
|
37
|
-
|
|
38
|
-
try {
|
|
39
|
-
await waitFor("prompt editor", async () => (await ui.state()).focused.editor)
|
|
40
|
-
await ui.typeText(
|
|
41
|
-
"Reproduce stale running status across an event-stream reconnect",
|
|
42
|
-
)
|
|
43
|
-
await ui.pressEnter()
|
|
44
|
-
await requestOpened.promise
|
|
45
|
-
await waitFor("running TUI", async () => isRunning(await ui.state()))
|
|
46
|
-
|
|
47
|
-
releaseResponse.resolve()
|
|
48
|
-
await responseFinished.promise
|
|
49
|
-
await Bun.sleep(1_000)
|
|
50
|
-
|
|
51
|
-
const state = await ui.state()
|
|
52
|
-
if (!isRunning(state))
|
|
53
|
-
throw new Error("stale running status was not reproduced")
|
|
54
|
-
console.log(
|
|
55
|
-
"REPRODUCED: backend provider work is idle while the TUI still displays running.",
|
|
56
|
-
)
|
|
57
|
-
await Bun.sleep(Number(process.env.OPENCODE_PROBE_HOLD_MS ?? "10000"))
|
|
58
|
-
} finally {
|
|
59
|
-
ui.close()
|
|
60
|
-
backend.close()
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
function deferred() {
|
|
64
|
-
let resolve!: () => void
|
|
65
|
-
const promise = new Promise<void>((done) => {
|
|
66
|
-
resolve = done
|
|
67
|
-
})
|
|
68
|
-
return { promise, resolve }
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
function requiredEnv(name: string) {
|
|
72
|
-
const value = process.env[name]
|
|
73
|
-
if (value === undefined) throw new Error(`${name} is required`)
|
|
74
|
-
return value
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
function isTitleRequest(request: OpenedExchange) {
|
|
78
|
-
if (
|
|
79
|
-
typeof request.body !== "object" ||
|
|
80
|
-
request.body === null ||
|
|
81
|
-
!("messages" in request.body)
|
|
82
|
-
)
|
|
83
|
-
return false
|
|
84
|
-
const messages = request.body.messages
|
|
85
|
-
if (!Array.isArray(messages)) return false
|
|
86
|
-
const first = messages[0]
|
|
87
|
-
if (typeof first !== "object" || first === null || !("content" in first))
|
|
88
|
-
return false
|
|
89
|
-
return (
|
|
90
|
-
typeof first.content === "string" &&
|
|
91
|
-
first.content.includes("You are a title generator")
|
|
92
|
-
)
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
async function waitFor(
|
|
96
|
-
label: string,
|
|
97
|
-
check: () => Promise<boolean>,
|
|
98
|
-
timeout = 30_000,
|
|
99
|
-
) {
|
|
100
|
-
const deadline = Date.now() + timeout
|
|
101
|
-
while (Date.now() < deadline) {
|
|
102
|
-
if (await check()) return
|
|
103
|
-
await Bun.sleep(50)
|
|
104
|
-
}
|
|
105
|
-
throw new Error(`timed out waiting for ${label}`)
|
|
106
|
-
}
|