Skip to content

Commit 5f4b9d3

Browse files
feat(opencode): add conservative workspace hot reload
1 parent 5bdf1c4 commit 5f4b9d3

5 files changed

Lines changed: 213 additions & 2 deletions

File tree

packages/opencode/src/file/watcher.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ export namespace FileWatcher {
4646

4747
const state = Instance.state(
4848
async () => {
49-
if (Instance.project.vcs !== "git") return {}
49+
if (Instance.project.vcs !== "git" && !Flag.OPENCODE_HOT_RELOAD) return {}
5050
log.info("init")
5151
const cfg = await Config.get()
5252
const backend = (() => {
@@ -75,7 +75,7 @@ export namespace FileWatcher {
7575
const subs: ParcelWatcher.AsyncSubscription[] = []
7676
const cfgIgnores = cfg.watcher?.ignore ?? []
7777

78-
if (Flag.OPENCODE_EXPERIMENTAL_FILEWATCHER) {
78+
if (Flag.OPENCODE_EXPERIMENTAL_FILEWATCHER || Flag.OPENCODE_HOT_RELOAD) {
7979
const pending = w.subscribe(Instance.directory, subscribe, {
8080
ignore: [...FileIgnore.PATTERNS, ...cfgIgnores],
8181
backend,

packages/opencode/src/flag/flag.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,9 @@ export namespace Flag {
3030
export declare const OPENCODE_CLIENT: string
3131
export const OPENCODE_SERVER_PASSWORD = process.env["OPENCODE_SERVER_PASSWORD"]
3232
export const OPENCODE_SERVER_USERNAME = process.env["OPENCODE_SERVER_USERNAME"]
33+
export const OPENCODE_HOT_RELOAD = truthy("OPENCODE_HOT_RELOAD")
34+
export const OPENCODE_HOT_RELOAD_DEBOUNCE_MS = number("OPENCODE_HOT_RELOAD_DEBOUNCE_MS")
35+
export const OPENCODE_HOT_RELOAD_COOLDOWN_MS = number("OPENCODE_HOT_RELOAD_COOLDOWN_MS")
3336

3437
// Experimental
3538
export const OPENCODE_EXPERIMENTAL = truthy("OPENCODE_EXPERIMENTAL")

packages/opencode/src/project/bootstrap.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { Log } from "@/util/log"
1313
import { ShareNext } from "@/share/share-next"
1414
import { Snapshot } from "../snapshot"
1515
import { Truncate } from "../tool/truncation"
16+
import { HotReload } from "./hotreload"
1617

1718
export async function InstanceBootstrap() {
1819
Log.Default.info("bootstrapping", { directory: Instance.directory })
@@ -22,6 +23,7 @@ export async function InstanceBootstrap() {
2223
Format.init()
2324
await LSP.init()
2425
FileWatcher.init()
26+
HotReload.init()
2527
File.init()
2628
Vcs.init()
2729
Snapshot.init()
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
import path from "path"
2+
import { Bus } from "@/bus"
3+
import { FileWatcher } from "@/file/watcher"
4+
import { Flag } from "@/flag/flag"
5+
import { Log } from "@/util/log"
6+
import { Instance } from "./instance"
7+
8+
export namespace HotReload {
9+
const log = Log.create({ service: "project.hotreload" })
10+
11+
const watched = new Set([
12+
"agent",
13+
"agents",
14+
"command",
15+
"commands",
16+
"mode",
17+
"modes",
18+
"plugin",
19+
"plugins",
20+
"skill",
21+
"skills",
22+
"tool",
23+
"tools",
24+
])
25+
26+
function normalize(file: string) {
27+
return file.split(path.sep).join("/")
28+
}
29+
30+
function temp(file: string) {
31+
const base = file.split("/").at(-1) ?? file
32+
if (!base) return true
33+
if (base === ".DS_Store" || base === "Thumbs.db") return true
34+
if (base.startsWith(".#")) return true
35+
if (base.endsWith("~")) return true
36+
if (base.endsWith(".tmp")) return true
37+
if (base.endsWith(".swp")) return true
38+
if (base.endsWith(".swo")) return true
39+
if (base.endsWith(".swx")) return true
40+
if (base.endsWith(".bak")) return true
41+
if (base.endsWith(".orig")) return true
42+
if (base.endsWith(".rej")) return true
43+
if (base.endsWith(".crdownload")) return true
44+
return false
45+
}
46+
47+
function rel(root: string, file: string) {
48+
const roots = new Set([normalize(root).replace(/\/+$/, "")])
49+
const files = new Set([normalize(file)])
50+
51+
if (process.platform === "darwin") {
52+
for (const item of [...roots]) {
53+
if (item.startsWith("/private/")) roots.add(item.slice("/private".length))
54+
if (item.startsWith("/var/")) roots.add(`/private${item}`)
55+
}
56+
for (const item of [...files]) {
57+
if (item.startsWith("/private/")) files.add(item.slice("/private".length))
58+
if (item.startsWith("/var/")) files.add(`/private${item}`)
59+
}
60+
}
61+
62+
for (const rootItem of roots) {
63+
for (const fileItem of files) {
64+
if (fileItem.includes("/.git/")) continue
65+
if (fileItem === rootItem) continue
66+
if (!fileItem.startsWith(`${rootItem}/`)) continue
67+
return fileItem.slice(rootItem.length + 1)
68+
}
69+
}
70+
}
71+
72+
export function classify(root: string, file: string) {
73+
const relFile = rel(root, file)
74+
if (!relFile) return
75+
if (temp(relFile)) return
76+
if (relFile === "opencode.json") return relFile
77+
if (relFile === "opencode.jsonc") return relFile
78+
if (relFile === "AGENTS.md") return relFile
79+
if (relFile === ".opencode/opencode.json") return relFile
80+
if (relFile === ".opencode/opencode.jsonc") return relFile
81+
if (!relFile.startsWith(".opencode/")) return
82+
if (relFile.startsWith(".opencode/openwork/")) return
83+
84+
const parts = relFile.split("/")
85+
if (parts.length < 3) return
86+
if (!watched.has(parts[1])) return
87+
88+
const base = parts.at(-1) ?? ""
89+
if (!base.includes(".")) return
90+
return relFile
91+
}
92+
93+
const state = Instance.state(
94+
() => {
95+
if (!Flag.OPENCODE_HOT_RELOAD) return {}
96+
97+
const debounce = Flag.OPENCODE_HOT_RELOAD_DEBOUNCE_MS ?? 700
98+
const cooldown = Flag.OPENCODE_HOT_RELOAD_COOLDOWN_MS ?? 1500
99+
let timer: ReturnType<typeof setTimeout> | undefined
100+
let busy = false
101+
let last = 0
102+
let latest:
103+
| {
104+
file: string
105+
event: "add" | "change" | "unlink"
106+
}
107+
| undefined
108+
109+
const flush = () => {
110+
timer = undefined
111+
if (busy) return
112+
113+
const now = Date.now()
114+
const wait = cooldown - (now - last)
115+
if (wait > 0) {
116+
timer = setTimeout(flush, wait)
117+
return
118+
}
119+
120+
const hit = latest
121+
if (!hit) return
122+
123+
busy = true
124+
last = now
125+
log.info("hot reload triggered", { file: hit.file, event: hit.event })
126+
void Instance.dispose()
127+
.catch((error) => {
128+
log.error("hot reload failed", { error, file: hit.file, event: hit.event })
129+
})
130+
.finally(() => {
131+
busy = false
132+
})
133+
}
134+
135+
const schedule = () => {
136+
if (timer) clearTimeout(timer)
137+
timer = setTimeout(flush, debounce)
138+
}
139+
140+
const unsub = Bus.subscribe(FileWatcher.Event.Updated, (event) => {
141+
const rel = classify(Instance.directory, event.properties.file)
142+
if (!rel) return
143+
latest = {
144+
file: rel,
145+
event: event.properties.event,
146+
}
147+
schedule()
148+
})
149+
150+
log.info("hot reload enabled", { debounce, cooldown })
151+
return {
152+
unsub,
153+
clear() {
154+
if (!timer) return
155+
clearTimeout(timer)
156+
timer = undefined
157+
},
158+
}
159+
},
160+
async (entry) => {
161+
entry.unsub?.()
162+
entry.clear?.()
163+
},
164+
)
165+
166+
export function init() {
167+
state()
168+
}
169+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { expect, test } from "bun:test"
2+
import { HotReload } from "../../src/project/hotreload"
3+
4+
const root = "/tmp/openwork-hotreload"
5+
6+
test("matches project config files", () => {
7+
expect(HotReload.classify(root, `${root}/opencode.json`)).toBe("opencode.json")
8+
expect(HotReload.classify(root, `${root}/opencode.jsonc`)).toBe("opencode.jsonc")
9+
expect(HotReload.classify(root, `${root}/AGENTS.md`)).toBe("AGENTS.md")
10+
})
11+
12+
test("matches opencode directories", () => {
13+
expect(HotReload.classify(root, `${root}/.opencode/skills/new-skill/SKILL.md`)).toBe(
14+
".opencode/skills/new-skill/SKILL.md",
15+
)
16+
expect(HotReload.classify(root, `${root}/.opencode/commands/fix.md`)).toBe(
17+
".opencode/commands/fix.md",
18+
)
19+
expect(HotReload.classify(root, `${root}/.opencode/plugins/example.ts`)).toBe(
20+
".opencode/plugins/example.ts",
21+
)
22+
})
23+
24+
test("ignores metadata, temp files, and unrelated files", () => {
25+
expect(HotReload.classify(root, `${root}/README.md`)).toBeUndefined()
26+
expect(HotReload.classify(root, `${root}/.opencode/openwork/openwork.json`)).toBeUndefined()
27+
expect(HotReload.classify(root, `${root}/.opencode/skills/new-skill/SKILL.md.swp`)).toBeUndefined()
28+
expect(HotReload.classify(root, `${root}/.git/HEAD`)).toBeUndefined()
29+
expect(HotReload.classify(root, `/tmp/other/opencode.json`)).toBeUndefined()
30+
})
31+
32+
test("matches darwin /private path aliases", () => {
33+
const privateRoot = "/private/tmp/openwork-hotreload"
34+
expect(HotReload.classify(privateRoot, "/tmp/openwork-hotreload/.opencode/commands/fix.md")).toBe(
35+
".opencode/commands/fix.md",
36+
)
37+
})

0 commit comments

Comments
 (0)