Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/desktop/src-tauri/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -391,9 +391,9 @@ pub fn spawn_command(
let _ = tx.send(CommandEvent::Error(err.to_string())).await;
}
}

stdout.abort();
stderr.abort();

});

let event_stream = ReceiverStream::new(rx);
Expand Down
4 changes: 4 additions & 0 deletions packages/opencode/src/agent/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,10 @@ export namespace Agent {
return state().then((x) => x[agent])
}

export async function reset() {
await state.reset()
}

export async function list() {
const cfg = await Config.get()
return pipe(
Expand Down
4 changes: 4 additions & 0 deletions packages/opencode/src/command/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,10 @@ export namespace Command {
return state().then((x) => x[name])
}

export async function reset() {
await state.reset()
}

export async function list() {
return state().then((x) => Object.values(x))
}
Expand Down
4 changes: 4 additions & 0 deletions packages/opencode/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1363,6 +1363,10 @@ export namespace Config {
return state().then((x) => x.config)
}

export async function reset() {
await state.reset()
}

export async function getGlobal() {
return global()
}
Expand Down
4 changes: 2 additions & 2 deletions packages/opencode/src/file/watcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ export namespace FileWatcher {

const state = Instance.state(
async () => {
if (Instance.project.vcs !== "git") return {}
if (Instance.project.vcs !== "git" && !Flag.OPENCODE_EXPERIMENTAL_HOT_RELOAD) return {}
log.info("init")
const cfg = await Config.get()
const backend = (() => {
Expand Down Expand Up @@ -75,7 +75,7 @@ export namespace FileWatcher {
const subs: ParcelWatcher.AsyncSubscription[] = []
const cfgIgnores = cfg.watcher?.ignore ?? []

if (Flag.OPENCODE_EXPERIMENTAL_FILEWATCHER) {
if (Flag.OPENCODE_EXPERIMENTAL_FILEWATCHER || Flag.OPENCODE_EXPERIMENTAL_HOT_RELOAD) {
const pending = w.subscribe(Instance.directory, subscribe, {
ignore: [...FileIgnore.PATTERNS, ...cfgIgnores],
backend,
Expand Down
2 changes: 2 additions & 0 deletions packages/opencode/src/flag/flag.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ export namespace Flag {

// Experimental
export const OPENCODE_EXPERIMENTAL = truthy("OPENCODE_EXPERIMENTAL")
export const OPENCODE_EXPERIMENTAL_HOT_RELOAD = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_HOT_RELOAD")
export const OPENCODE_EXPERIMENTAL_HOT_RELOAD_COOLDOWN_MS = number("OPENCODE_EXPERIMENTAL_HOT_RELOAD_COOLDOWN_MS")
export const OPENCODE_EXPERIMENTAL_FILEWATCHER = truthy("OPENCODE_EXPERIMENTAL_FILEWATCHER")
export const OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER = truthy("OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER")
export const OPENCODE_EXPERIMENTAL_ICON_DISCOVERY =
Expand Down
4 changes: 4 additions & 0 deletions packages/opencode/src/mcp/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,10 @@ export namespace MCP {
},
)

export async function reset() {
await state.reset()
}

// Helper function to fetch prompts for a specific client
async function fetchPromptsForClient(clientName: string, client: Client) {
const prompts = await client.listPrompts().catch((e) => {
Expand Down
4 changes: 4 additions & 0 deletions packages/opencode/src/plugin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,10 @@ export namespace Plugin {
return state().then((x) => x.hooks)
}

export async function reset() {
await state.reset()
}

export async function init() {
const hooks = await state().then((x) => x.hooks)
const config = await Config.get()
Expand Down
2 changes: 2 additions & 0 deletions packages/opencode/src/project/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { Log } from "@/util/log"
import { ShareNext } from "@/share/share-next"
import { Snapshot } from "../snapshot"
import { Truncate } from "../tool/truncation"
import { HotReload } from "./hotreload"

export async function InstanceBootstrap() {
Log.Default.info("bootstrapping", { directory: Instance.directory })
Expand All @@ -20,6 +21,7 @@ export async function InstanceBootstrap() {
Format.init()
await LSP.init()
FileWatcher.init()
HotReload.init()
File.init()
Vcs.init()
Snapshot.init()
Expand Down
261 changes: 261 additions & 0 deletions packages/opencode/src/project/hotreload.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,261 @@
import path from "path"
import { Bus } from "@/bus"
import { BusEvent } from "@/bus/bus-event"
import { Agent } from "@/agent/agent"
import { Command } from "@/command"
import { Config } from "@/config/config"
import { FileWatcher } from "@/file/watcher"
import { Flag } from "@/flag/flag"
import { MCP } from "@/mcp"
import { Plugin } from "@/plugin"
import { SessionStatus } from "@/session/status"
import { Skill } from "@/skill"
import { ToolRegistry } from "@/tool/registry"
import { Log } from "@/util/log"
import { Instance } from "./instance"
import z from "zod"

export namespace HotReload {
const log = Log.create({ service: "project.hotreload" })

export const Event = {
Changed: BusEvent.define(
"opencode.hotreload.changed",
z.object({
file: z.string(),
event: z.enum(["add", "change", "unlink"]),
}),
),
Applied: BusEvent.define(
"opencode.hotreload.applied",
z.object({
file: z.string(),
event: z.enum(["add", "change", "unlink"]),
}),
),
}

const watched = new Set([
"agent",
"agents",
"command",
"commands",
"mode",
"modes",
"plugin",
"plugins",
"skill",
"skills",
"tool",
"tools",
])

function normalize(file: string) {
return file.split(path.sep).join("/")
}

function temp(file: string) {
const base = file.split("/").at(-1) ?? file
if (!base) return true
if (base === ".DS_Store" || base === "Thumbs.db") return true
if (base.startsWith(".#")) return true
if (base.endsWith("~")) return true
if (base.endsWith(".tmp")) return true
if (base.endsWith(".swp")) return true
if (base.endsWith(".swo")) return true
if (base.endsWith(".swx")) return true
if (base.endsWith(".bak")) return true
if (base.endsWith(".orig")) return true
if (base.endsWith(".rej")) return true
if (base.endsWith(".crdownload")) return true
return false
}

function rel(root: string, file: string) {
const roots = new Set([normalize(root).replace(/\/+$/, "")])
const files = new Set([normalize(file)])

if (process.platform === "darwin") {
for (const item of [...roots]) {
if (item.startsWith("/private/")) roots.add(item.slice("/private".length))
if (item.startsWith("/var/")) roots.add(`/private${item}`)
}
for (const item of [...files]) {
if (item.startsWith("/private/")) files.add(item.slice("/private".length))
if (item.startsWith("/var/")) files.add(`/private${item}`)
}
}

for (const rootItem of roots) {
for (const fileItem of files) {
if (fileItem.includes("/.git/")) continue
if (fileItem === rootItem) continue
if (!fileItem.startsWith(`${rootItem}/`)) continue
return fileItem.slice(rootItem.length + 1)
}
}
}

export function classify(root: string, file: string) {
const relFile = rel(root, file)
if (!relFile) return
if (temp(relFile)) return
if (relFile === "opencode.json") return relFile
if (relFile === "opencode.jsonc") return relFile
if (relFile === "AGENTS.md") return relFile
if (relFile === ".opencode/opencode.json") return relFile
if (relFile === ".opencode/opencode.jsonc") return relFile
if (!relFile.startsWith(".opencode/")) return
if (relFile.startsWith(".opencode/openwork/")) return

const parts = relFile.split("/")
if (parts.length < 3) return
if (!watched.has(parts[1])) return

const base = parts.at(-1) ?? ""
if (!base.includes(".")) return
return relFile
}

const state = Instance.state(
() => {
if (!Flag.OPENCODE_EXPERIMENTAL_HOT_RELOAD) return {}

const cooldown = Flag.OPENCODE_EXPERIMENTAL_HOT_RELOAD_COOLDOWN_MS ?? 1500
let timer: ReturnType<typeof setTimeout> | undefined
let busy = false
let last = 0
let queued = false
let latest:
| {
file: string
event: "add" | "change" | "unlink"
}
| undefined

const active = () =>
Object.values(SessionStatus.list()).filter((status) => status.type === "busy" || status.type === "retry").length

const reload = async () => {
await Config.reset()
await Plugin.reset()
await MCP.reset()
await ToolRegistry.reset()
await Skill.reset()
await Agent.reset()
await Command.reset()
}

const flush = (reason: "timer" | "session" | "api") => {
timer = undefined
if (busy) return { ok: true, queued, sessions: active() }

const hit = latest
if (!hit) return { ok: true, queued, sessions: active() }

const sessions = active()
if (sessions > 0) {
if (!queued) {
log.info("hot reload queued", {
file: hit.file,
event: hit.event,
sessions,
})
}
queued = true
return { ok: true, queued: true, sessions }
}

const now = Date.now()
const wait = cooldown - (now - last)
if (wait > 0) {
timer = setTimeout(() => flush(reason), wait)
return { ok: true, queued: false, sessions, wait }
}

busy = true
queued = false
latest = undefined
last = now
const directory = Instance.directory
log.info("hot reload triggered", { directory, file: hit.file, event: hit.event, reason })
void Instance.provide({
directory,
async fn() {
await reload()
await Bus.publish(Event.Applied, {
file: hit.file,
event: hit.event,
})
},
})
.catch((error) => {
log.error("hot reload failed", { error, directory, file: hit.file, event: hit.event })
})
.finally(() => {
busy = false
if (!latest) return
if (timer) clearTimeout(timer)
timer = setTimeout(() => flush("timer"), 0)
})
return { ok: true, queued: false, sessions }
}

const request = (hit: { file: string; event: "add" | "change" | "unlink" }) => {
latest = hit
return flush("api")
}

const unsubFile = Bus.subscribe(FileWatcher.Event.Updated, (event) => {
const rel = classify(Instance.directory, event.properties.file)
if (!rel) return

const hit = {
file: rel,
event: event.properties.event,
} as const

void Bus.publish(Event.Changed, hit)
})

const unsubSession = Bus.subscribe(SessionStatus.Event.Status, () => {
if (!queued) return
if (timer) return
timer = setTimeout(() => flush("session"), 0)
})

log.info("hot reload enabled", { cooldown, mode: "manual" })
return {
unsubFile,
unsubSession,
request,
clear() {
if (!timer) return
clearTimeout(timer)
timer = undefined
},
}
},
async (entry) => {
entry.unsubFile?.()
entry.unsubSession?.()
entry.clear?.()
},
)

export function init() {
state()
}

export function request(input?: { file?: string; event?: "add" | "change" | "unlink" }) {
const entry = state()
const req = "request" in entry ? entry.request : undefined
if (!req) {
return { ok: false, enabled: false }
}
const file = input?.file?.trim() || "api"
const event = input?.event || "change"
const result = req({ file, event })
return { ...result, enabled: true }
}
}
2 changes: 1 addition & 1 deletion packages/opencode/src/project/instance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ export const Instance = {
if (Instance.worktree === "/") return false
return Filesystem.contains(Instance.worktree, filepath)
},
state<S>(init: () => S, dispose?: (state: Awaited<S>) => Promise<void>): () => S {
state<S>(init: () => S, dispose?: (state: Awaited<S>) => Promise<void>): State.Accessor<S> {
return State.create(() => Instance.directory, init, dispose)
},
async dispose() {
Expand Down
Loading