fix(opencode): don't crash the whole session when plugins/lib is missing (#2538)

* fix(opencode): don't crash the whole session when plugins/lib is missing (#2530)

* fix: address review feedback (trailing newlines, symptom wording)

* fix(opencode): also lazy-load the store in ecc-hooks.ts plugin entrypoint

* test(opencode): add regression coverage for missing/present plugins/lib in ecc-hooks

* fix(opencode): guard diagnostic log() against unhandled rejection; tighten test assertion

* fix(opencode): only publish store after init succeeds; scrub loader errors from warning; catch sync log throws

* fix(opencode): scrub raw loader error from changed-files tool; add tool-level regression tests

* fix(ci): make OpenCode checks cross-platform

---------

Co-authored-by: haelyra <49814733+haelyra@users.noreply.github.com>
This commit is contained in:
Shanujan Suresh
2026-07-28 21:37:41 -04:00
committed by GitHub
co-authored by haelyra
parent afa34d1aca
commit 591ab5cbd3
5 changed files with 267 additions and 19 deletions
+37 -12
View File
@@ -16,11 +16,6 @@
import type { PluginInput } from "@opencode-ai/plugin"
import * as fs from "fs"
import * as path from "path"
import {
initStore,
recordChange,
clearChanges,
} from "./lib/changed-files-store.js"
import changedFilesTool from "../tools/changed-files.js"
import dependencyAnalyzerTool from "../tools/dependency-analyzer.js"
@@ -80,7 +75,6 @@ export const ECCHooksPlugin: ECCHooksPluginFn = async ({
type HookProfile = "minimal" | "standard" | "strict"
const worktreePath = worktree || directory
initStore(worktreePath)
const editedFiles = new Set<string>()
@@ -110,6 +104,37 @@ export const ECCHooksPlugin: ECCHooksPluginFn = async ({
const log = (level: "debug" | "info" | "warn" | "error", message: string) =>
client.app.log({ body: { service: "ecc", level, message } })
// Loaded lazily (instead of via a top-level import) so that a missing or
// partially-installed `~/.opencode/plugins/lib` directory (e.g. an
// interrupted or partial ECC install on Termux/Android) only disables
// changed-files tracking, rather than throwing during module evaluation.
// This plugin is OpenCode's startup entry point, so a static import
// failure here previously crashed the whole plugin -- and with it, the
// entire OpenCode session -- before any hooks could load (see #2530).
let changedFilesStore: typeof import("./lib/changed-files-store.js") | undefined
try {
const store = await import("./lib/changed-files-store.js")
store.initStore(worktreePath)
changedFilesStore = store
} catch {
// Best-effort diagnostic only: deferred via .then() (rather than
// Promise.resolve(log(...))) so that even a *synchronous* throw inside
// log() -- not just an async rejection -- is caught here instead of
// escaping this catch block. The raw loader error is intentionally not
// included in the message since it can contain absolute filesystem
// paths; this whole block exists to guarantee startup resilience even
// when things go wrong.
Promise.resolve()
.then(() =>
log(
"warn",
"[ECC] changed-files tracking disabled: could not load the changed-files store. " +
"Run `ecc repair --target opencode` to restore the missing files. Other ECC hooks are unaffected."
)
)
.catch(() => {})
}
const normalizeProfile = (value: string | undefined): HookProfile => {
if (value === "minimal" || value === "strict") return value
return "standard"
@@ -154,7 +179,7 @@ export const ECCHooksPlugin: ECCHooksPluginFn = async ({
*/
"file.edited": async (event: { path: string }) => {
editedFiles.add(event.path)
recordChange(event.path, "modified")
changedFilesStore?.recordChange(event.path, "modified")
// Auto-format JS/TS files
if (hookEnabled("post:edit:format", ["strict"]) && event.path.match(/\.(ts|tsx|js|jsx)$/)) {
@@ -198,16 +223,16 @@ export const ECCHooksPlugin: ECCHooksPluginFn = async ({
) => {
const filePath = getFilePath(input.args)
if (input.tool === "edit" && filePath) {
recordChange(filePath, "modified")
changedFilesStore?.recordChange(filePath, "modified")
}
if (input.tool === "write" && filePath) {
const key = input.callID ?? `write-${++writeCounter}-${filePath}`
const pending = pendingToolChanges.get(key)
if (pending) {
recordChange(pending.path, pending.type)
changedFilesStore?.recordChange(pending.path, pending.type)
pendingToolChanges.delete(key)
} else {
recordChange(filePath, "modified")
changedFilesStore?.recordChange(filePath, "modified")
}
}
@@ -413,7 +438,7 @@ export const ECCHooksPlugin: ECCHooksPluginFn = async ({
if (!hookEnabled("session:end-marker", ["minimal", "standard", "strict"])) return
log("info", "[ECC] Session ended - cleaning up")
editedFiles.clear()
clearChanges()
changedFilesStore?.clearChanges()
pendingToolChanges.clear()
},
@@ -428,7 +453,7 @@ export const ECCHooksPlugin: ECCHooksPluginFn = async ({
let changeType: "added" | "modified" | "deleted" = "modified"
if (event.type === "create" || event.type === "add") changeType = "added"
else if (event.type === "delete" || event.type === "remove") changeType = "deleted"
recordChange(event.path, changeType)
changedFilesStore?.recordChange(event.path, changeType)
if (event.type === "change" && event.path.match(/\.(ts|tsx|js|jsx)$/)) {
editedFiles.add(event.path)
}
+28 -7
View File
@@ -1,11 +1,5 @@
import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool"
import {
buildTree,
getChangedPaths,
hasChanges,
type ChangeType,
type TreeNode,
} from "../plugins/lib/changed-files-store.js"
import type { ChangeType, TreeNode } from "../plugins/lib/changed-files-store.js"
const INDICATORS: Record<ChangeType, string> = {
added: "+",
@@ -26,6 +20,32 @@ function renderTree(nodes: TreeNode[], indent: string): string {
return lines.join("\n")
}
// Loaded lazily (instead of via a top-level import) so that a missing or
// partially-installed `~/.opencode/plugins` directory only breaks this one
// tool when it's actually invoked, rather than throwing during module
// evaluation. `tools/index.ts` re-exports every tool from a single barrel
// file, so a static import failure here previously took down the entire
// tools module -- and with it, the whole OpenCode session -- on the very
// first tool-loading pass (see #2530).
type ChangedFilesStore = typeof import("../plugins/lib/changed-files-store.js")
let changedFilesStorePromise: Promise<ChangedFilesStore> | undefined
async function loadChangedFilesStore(): Promise<ChangedFilesStore> {
if (!changedFilesStorePromise) {
changedFilesStorePromise = import("../plugins/lib/changed-files-store.js").catch(() => {
changedFilesStorePromise = undefined
throw new Error(
"changed-files tool: could not load the changed-files store. " +
"This usually means the ~/.opencode/plugins directory is missing or incomplete " +
"(an interrupted or partial ECC install can leave tools/ populated without plugins/). " +
"Run `node scripts/repair.js --target opencode` (or `ecc repair --target opencode`) " +
"from the ECC repo to restore the missing files."
)
})
}
return changedFilesStorePromise
}
const changedFilesTool: ToolDefinition = tool({
description:
"List files changed by agents in this session as a navigable tree. Shows added (+), modified (~), and deleted (-) indicators. Use filter to show only specific change types. Returns paths for git diff.",
@@ -40,6 +60,7 @@ const changedFilesTool: ToolDefinition = tool({
.describe("Output format: tree for terminal display, json for structured data (default: tree)"),
},
async execute(args, context) {
const { buildTree, getChangedPaths, hasChanges } = await loadChangedFilesStore()
const filter = args.filter === "all" || !args.filter ? undefined : (args.filter as ChangeType)
const format = args.format ?? "tree"