Skip to content

Commit a6f2f4c

Browse files
feat(experimental): add hot reload API trigger
1 parent 15f0b4f commit a6f2f4c

4 files changed

Lines changed: 91 additions & 19 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" && !Flag.OPENCODE_HOT_RELOAD) return {}
49+
if (Instance.project.vcs !== "git" && !Flag.OPENCODE_EXPERIMENTAL_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 || Flag.OPENCODE_HOT_RELOAD) {
78+
if (Flag.OPENCODE_EXPERIMENTAL_FILEWATCHER || Flag.OPENCODE_EXPERIMENTAL_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: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,12 +30,13 @@ 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+
// Experimental
34+
export const OPENCODE_EXPERIMENTAL = truthy("OPENCODE_EXPERIMENTAL")
3335
export const OPENCODE_HOT_RELOAD = truthy("OPENCODE_HOT_RELOAD")
36+
export const OPENCODE_EXPERIMENTAL_HOT_RELOAD =
37+
OPENCODE_EXPERIMENTAL || OPENCODE_HOT_RELOAD || truthy("OPENCODE_EXPERIMENTAL_HOT_RELOAD")
3438
export const OPENCODE_HOT_RELOAD_DEBOUNCE_MS = number("OPENCODE_HOT_RELOAD_DEBOUNCE_MS")
3539
export const OPENCODE_HOT_RELOAD_COOLDOWN_MS = number("OPENCODE_HOT_RELOAD_COOLDOWN_MS")
36-
37-
// Experimental
38-
export const OPENCODE_EXPERIMENTAL = truthy("OPENCODE_EXPERIMENTAL")
3940
export const OPENCODE_EXPERIMENTAL_FILEWATCHER = truthy("OPENCODE_EXPERIMENTAL_FILEWATCHER")
4041
export const OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER = truthy("OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER")
4142
export const OPENCODE_EXPERIMENTAL_ICON_DISCOVERY =

packages/opencode/src/project/hotreload.ts

Lines changed: 37 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ export namespace HotReload {
109109

110110
const state = Instance.state(
111111
() => {
112-
if (!Flag.OPENCODE_HOT_RELOAD) return {}
112+
if (!Flag.OPENCODE_EXPERIMENTAL_HOT_RELOAD) return {}
113113

114114
const debounce = Flag.OPENCODE_HOT_RELOAD_DEBOUNCE_MS ?? 700
115115
const cooldown = Flag.OPENCODE_HOT_RELOAD_COOLDOWN_MS ?? 1500
@@ -134,12 +134,17 @@ export namespace HotReload {
134134
await Command.reset()
135135
}
136136

137-
const flush = (reason: "timer" | "session") => {
137+
const schedule = () => {
138+
if (timer) clearTimeout(timer)
139+
timer = setTimeout(() => flush("timer"), debounce)
140+
}
141+
142+
const flush = (reason: "timer" | "session" | "api") => {
138143
timer = undefined
139-
if (busy) return
144+
if (busy) return { ok: true, queued, sessions: active() }
140145

141146
const hit = latest
142-
if (!hit) return
147+
if (!hit) return { ok: true, queued, sessions: active() }
143148

144149
const sessions = active()
145150
if (sessions > 0) {
@@ -151,14 +156,14 @@ export namespace HotReload {
151156
})
152157
}
153158
queued = true
154-
return
159+
return { ok: true, queued: true, sessions }
155160
}
156161

157162
const now = Date.now()
158163
const wait = cooldown - (now - last)
159164
if (wait > 0) {
160165
timer = setTimeout(() => flush(reason), wait)
161-
return
166+
return { ok: true, queued: false, sessions, wait }
162167
}
163168

164169
busy = true
@@ -182,21 +187,26 @@ export namespace HotReload {
182187
if (timer) clearTimeout(timer)
183188
timer = setTimeout(() => flush("timer"), debounce)
184189
})
190+
return { ok: true, queued: false, sessions }
185191
}
186192

187-
const schedule = () => {
188-
if (timer) clearTimeout(timer)
189-
timer = setTimeout(() => flush("timer"), debounce)
193+
const request = (hit: { file: string; event: "add" | "change" | "unlink" }, mode: "file" | "api") => {
194+
latest = hit
195+
if (mode === "api") return flush("api")
196+
schedule()
197+
return { ok: true, queued, sessions: active() }
190198
}
191199

192200
const unsubFile = Bus.subscribe(FileWatcher.Event.Updated, (event) => {
193201
const rel = classify(Instance.directory, event.properties.file)
194202
if (!rel) return
195-
latest = {
196-
file: rel,
197-
event: event.properties.event,
198-
}
199-
schedule()
203+
void request(
204+
{
205+
file: rel,
206+
event: event.properties.event,
207+
},
208+
"file",
209+
)
200210
})
201211

202212
const unsubSession = Bus.subscribe(SessionStatus.Event.Status, () => {
@@ -209,6 +219,7 @@ export namespace HotReload {
209219
return {
210220
unsubFile,
211221
unsubSession,
222+
request,
212223
clear() {
213224
if (!timer) return
214225
clearTimeout(timer)
@@ -226,4 +237,16 @@ export namespace HotReload {
226237
export function init() {
227238
state()
228239
}
240+
241+
export function request(input?: { file?: string; event?: "add" | "change" | "unlink" }) {
242+
const entry = state()
243+
const req = "request" in entry ? entry.request : undefined
244+
if (!req) {
245+
return { ok: false, enabled: false }
246+
}
247+
const file = input?.file?.trim() || "api"
248+
const event = input?.event || "change"
249+
const result = req({ file, event }, "api")
250+
return { ...result, enabled: true }
251+
}
229252
}

packages/opencode/src/server/routes/experimental.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,60 @@ import { Worktree } from "../../worktree"
66
import { Instance } from "../../project/instance"
77
import { Project } from "../../project/project"
88
import { MCP } from "../../mcp"
9+
import { HotReload } from "../../project/hotreload"
10+
import { Flag } from "../../flag/flag"
911
import { zodToJsonSchema } from "zod-to-json-schema"
1012
import { errors } from "../error"
1113
import { lazy } from "../../util/lazy"
1214

1315
export const ExperimentalRoutes = lazy(() =>
1416
new Hono()
17+
.post(
18+
"/hotreload",
19+
describeRoute({
20+
summary: "Apply hot reload",
21+
description:
22+
"Trigger an in-place reload of cached config/skills/agents/commands for the current instance. This is experimental and session-aware.",
23+
operationId: "experimental.hotreload.apply",
24+
responses: {
25+
200: {
26+
description: "Hot reload scheduled",
27+
content: {
28+
"application/json": {
29+
schema: resolver(
30+
z
31+
.object({
32+
ok: z.boolean(),
33+
enabled: z.boolean(),
34+
queued: z.boolean().optional(),
35+
sessions: z.number().optional(),
36+
wait: z.number().optional(),
37+
})
38+
.meta({ ref: "ExperimentalHotReloadResult" }),
39+
),
40+
},
41+
},
42+
},
43+
...errors(400),
44+
},
45+
}),
46+
validator(
47+
"json",
48+
z
49+
.object({
50+
file: z.string().optional(),
51+
event: z.enum(["add", "change", "unlink"]).optional(),
52+
})
53+
.optional(),
54+
),
55+
async (c) => {
56+
if (!Flag.OPENCODE_EXPERIMENTAL_HOT_RELOAD) {
57+
return c.json({ ok: false, enabled: false }, 400)
58+
}
59+
const body = c.req.valid("json")
60+
return c.json(HotReload.request(body ?? undefined))
61+
},
62+
)
1563
.get(
1664
"/tool/ids",
1765
describeRoute({

0 commit comments

Comments
 (0)