Skip to content
38 changes: 26 additions & 12 deletions .opencode/plugins/ecc-hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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>()

Expand Down Expand Up @@ -110,6 +104,26 @@ 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 {
changedFilesStore = await import("./lib/changed-files-store.js")
changedFilesStore.initStore(worktreePath)
} catch (error) {
const reason = error instanceof Error ? error.message : String(error)
log(
"warn",
`[ECC] changed-files tracking disabled: could not load './lib/changed-files-store.js' (${reason}). ` +
"Run `ecc repair --target opencode` to restore the missing files. Other ECC hooks are unaffected."
)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const normalizeProfile = (value: string | undefined): HookProfile => {
if (value === "minimal" || value === "strict") return value
return "standard"
Expand Down Expand Up @@ -154,7 +168,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)$/)) {
Expand Down Expand Up @@ -198,16 +212,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")
}
}

Expand Down Expand Up @@ -413,7 +427,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()
},

Expand All @@ -428,7 +442,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)
}
Expand Down
36 changes: 29 additions & 7 deletions .opencode/tools/changed-files.ts
Original file line number Diff line number Diff line change
@@ -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: "+",
Expand All @@ -26,6 +20,33 @@ 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((error) => {
changedFilesStorePromise = undefined
const reason = error instanceof Error ? error.message : String(error)
throw new Error(
"changed-files tool: could not load '../plugins/lib/changed-files-store.js'. " +
"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. Original error: ${reason}`
)
})
}
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.",
Expand All @@ -40,6 +61,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"

Expand Down
40 changes: 39 additions & 1 deletion TROUBLESHOOTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,44 @@ npm pkg set packageManager="pnpm@8.15.0"
rm package-lock.json # If using pnpm/yarn/bun
```

### OpenCode Fails to Start on Termux/Android

**Symptom:** Changed-files tracking silently stops working (a one-time
`[ECC] changed-files tracking disabled` warning appears in the OpenCode
logs), or (on older versions) `opencode` crashes on startup entirely with a
Bun `ResolveMessage`, e.g.:

```
ResolveMessage: Cannot find module '../plugins/lib/changed-files-store.js' from '.../.opencode/tools/changed-files.ts'
```

**Causes:**
- The `~/.opencode` install is missing or incomplete for this machine —
usually `tools/` and `plugins/` are present but `plugins/lib/` never
finished copying (an interrupted install, or a storage/permission hiccup
that's more common on Android's filesystem). Both the `changed-files` tool
and the `ecc-hooks` plugin depend on `plugins/lib/changed-files-store.js`;
since `ecc-hooks.ts` is OpenCode's plugin entry point (loaded once at
session startup, before `tools/index.ts`'s barrel file), a missing
dependency there used to crash the entire OpenCode session before any
hooks could load — not just the one tool.

**Solutions:**
```bash
# From the ECC repo, check for and repair missing/incomplete managed files
ecc doctor --target opencode
ecc repair --target opencode

# If that reports no drift but plugins/ is still missing on the device,
# re-run the ECC installer for the opencode target
```

**Note:** If you're also seeing `ProviderModelNotFoundError: Model not found: openai/gpt-5.5`
referencing `~/.config/opencode/oh-my-opencode-slim.json`, that file belongs to the
third-party [`oh-my-opencode-slim`](https://github.com/alvinunreal/oh-my-opencode-slim)
plugin, not ECC — ECC never writes to `~/.config/opencode/`. Fix the model prefix
(`opencode/...` instead of `openai/...`) there, or file it against that project.

---

## Performance Issues
Expand Down Expand Up @@ -429,4 +467,4 @@ find ~/.claude/plugins -name "*.sh" -exec dos2unix {} \;
- [README.md](./README.md) - Installation and features
Comment thread
shanujans marked this conversation as resolved.
- [CONTRIBUTING.md](./CONTRIBUTING.md) - Development guidelines
- [docs/](./docs/) - Detailed documentation
- [examples/](./examples/) - Usage examples
- [examples/](./examples/) - Usage examples
76 changes: 76 additions & 0 deletions tests/opencode-plugin-hooks.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,82 @@ async function main() {

const { ECCHooksPlugin } = await loadPlugin()
const tests = [
[
"plugin initializes and hooks stay usable when plugins/lib is missing",
async () => withTempProject([], async (projectDir) => {
const repoRoot = path.join(__dirname, "..")
const libDir = path.join(repoRoot, ".opencode", "dist", "plugins", "lib")
const backupDir = path.join(
repoRoot,
".opencode",
"dist",
"plugins",
"lib.missing-store-test-backup"
)
fs.renameSync(libDir, backupDir)
try {
const client = createClient()
const $ = createFailingShell()

// Plugin initialization must resolve even though changed-files-store.js
// cannot be found -- it must not throw and crash session startup (#2530).
const hooks = await ECCHooksPlugin({ client, $, directory: projectDir })

assert.ok(
client.logs.some(
(entry) =>
entry.level === "warn" &&
entry.message.includes("[ECC] changed-files tracking disabled") &&
entry.message.includes("ecc repair --target opencode")
),
"Expected a one-time warning when plugins/lib/changed-files-store.js cannot be loaded"
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

// Every hook that touches the store must remain callable and must not throw.
await hooks["file.edited"]({ path: "src/example.ts" })
await hooks["tool.execute.after"]({ tool: "edit", args: { path: "src/other.ts" } }, {})
await hooks["session.deleted"]()
} finally {
fs.renameSync(backupDir, libDir)
}
}),
],
[
"changed-files tracking records and clears through the plugin hooks",
async () => withTempProject([], async (projectDir) => {
const client = createClient()
const $ = createFailingShell()

const hooks = await ECCHooksPlugin({ client, $, directory: projectDir })

assert.ok(
!client.logs.some(
(entry) => entry.level === "warn" && entry.message.includes("changed-files tracking disabled")
),
"Did not expect a disabled warning when plugins/lib is present"
)

const storeUrl = pathToFileURL(
path.join(__dirname, "..", ".opencode", "dist", "plugins", "lib", "changed-files-store.js")
).href
const store = await import(storeUrl)

await hooks["file.edited"]({ path: "src/example.ts" })
assert.ok(
store.getChangedPaths().some((entry) => entry.path === "src/example.ts" && entry.changeType === "modified"),
"Expected file.edited to record a change via the plugin hook"
)

await hooks["tool.execute.after"]({ tool: "edit", args: { path: "src/other.ts" } }, {})
assert.ok(
store.getChangedPaths().some((entry) => entry.path === "src/other.ts"),
"Expected tool.execute.after to record a change for the edit tool"
)

await hooks["session.deleted"]()
assert.ok(!store.hasChanges(), "Expected session.deleted to clear tracked changes")
}),
],
[
"shell.env detects project markers without shelling out to test -f",
async () => withTempProject(
Expand Down