Skip to content

Commit 0801427

Browse files
authored
fix: prevent memory leaks in background agent, tool cache, and comment checker (#167)
* docs: regenerate AGENTS.md with updated project knowledge - Fixed agent name OmO → Sisyphus - Added CI PIPELINE section documenting workflow patterns - Fixed testing documentation (Bun test framework with BDD pattern) - Added README.zh-cn.md to multi-language docs list - Added `bun test` command to COMMANDS section - Added anti-patterns: Over-exploration, Date references - Updated convention: Test style with BDD comments - Added script/generate-changelog.ts to structure - Updated timestamp (2025-12-22) and git commit reference (aad7a72) 🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode) * fix: prevent memory leaks in background agent, tool cache, and comment checker (#165) - Add TTL-based cleanup for completed tasks in BackgroundManager - Add cache size limits with FIFO eviction for tool input cache - Lazy-load setInterval timers to prevent blocking process exit - Use timer.unref()/interval.unref() to allow graceful shutdown - Add process exit handlers for proper cleanup closes #165 🤖 Generated with assistance of [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
1 parent 858e3d5 commit 0801427

File tree

4 files changed

+130
-27
lines changed

4 files changed

+130
-27
lines changed

AGENTS.md

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
# PROJECT KNOWLEDGE BASE
22

3-
**Generated:** 2025-12-16T16:00:00+09:00
4-
**Commit:** a2d2109
5-
**Branch:** master
3+
**Generated:** 2025-12-22T02:23:00+09:00
4+
**Commit:** aad7a72
5+
**Branch:** dev
66

77
## OVERVIEW
88

@@ -13,16 +13,16 @@ OpenCode plugin implementing Claude Code/AmpCode features. Multi-model agent orc
1313
```
1414
oh-my-opencode/
1515
├── src/
16-
│ ├── agents/ # AI agents (OmO, oracle, librarian, explore, frontend, document-writer, multimodal-looker)
16+
│ ├── agents/ # AI agents (Sisyphus, oracle, librarian, explore, frontend, document-writer, multimodal-looker)
1717
│ ├── hooks/ # 21 lifecycle hooks (comment-checker, rules-injector, keyword-detector, etc.)
1818
│ ├── tools/ # LSP (11), AST-Grep, Grep, Glob, background-task, look-at, skill, slashcommand, interactive-bash, call-omo-agent
1919
│ ├── mcp/ # MCP servers (context7, websearch_exa, grep_app)
20-
│ ├── features/ # Terminal, Background agent, Claude Code loaders (agent, command, skill, mcp, session-state), hook-message-injector
20+
│ ├── features/ # Background agent, Claude Code loaders (agent, command, skill, mcp, session-state), hook-message-injector
2121
│ ├── config/ # Zod schema, TypeScript types
2222
│ ├── auth/ # Google Antigravity OAuth
2323
│ ├── shared/ # Utilities (deep-merge, pattern-matcher, logger, etc.)
2424
│ └── index.ts # Main plugin entry (OhMyOpenCodePlugin)
25-
├── script/ # build-schema.ts, publish.ts
25+
├── script/ # build-schema.ts, publish.ts, generate-changelog.ts
2626
├── assets/ # JSON schema
2727
└── dist/ # Build output (ESM + .d.ts)
2828
```
@@ -52,6 +52,7 @@ oh-my-opencode/
5252
- **Directory naming**: kebab-case (`ast-grep/`, `claude-code-hooks/`)
5353
- **Tool structure**: Each tool has index.ts, types.ts, constants.ts, tools.ts, utils.ts
5454
- **Hook pattern**: `createXXXHook(input: PluginInput)` returning event handlers
55+
- **Test style**: BDD comments `#given`, `#when`, `#then` (same as AAA pattern)
5556

5657
## ANTI-PATTERNS (THIS PROJECT)
5758

@@ -63,6 +64,7 @@ oh-my-opencode/
6364
- **Local version bump**: Version managed by CI workflow, never modify locally
6465
- **Rush completion**: Never mark tasks complete without verification
6566
- **Interrupting work**: Complete tasks fully before stopping
67+
- **Over-exploration**: Stop searching when sufficient context found
6668

6769
## UNIQUE STYLES
6870

@@ -73,12 +75,13 @@ oh-my-opencode/
7375
- **Agent tools restriction**: Use `tools: { include: [...] }` or `tools: { exclude: [...] }`
7476
- **Temperature**: Most agents use `0.1` for consistency
7577
- **Hook naming**: `createXXXHook` function naming convention
78+
- **Date references**: NEVER use 2024 in code/prompts (use current year)
7679

7780
## AGENT MODELS
7881

7982
| Agent | Model | Purpose |
8083
|-------|-------|---------|
81-
| OmO | anthropic/claude-opus-4-5 | Primary orchestrator, team leader |
84+
| Sisyphus | anthropic/claude-opus-4-5 | Primary orchestrator, team leader |
8285
| oracle | openai/gpt-5.2 | Strategic advisor, code review, architecture |
8386
| librarian | anthropic/claude-sonnet-4-5 | Multi-repo analysis, docs lookup, GitHub examples |
8487
| explore | opencode/grok-code | Fast codebase exploration, file patterns |
@@ -100,6 +103,9 @@ bun run rebuild
100103

101104
# Build schema only
102105
bun run build:schema
106+
107+
# Run tests
108+
bun test
103109
```
104110

105111
## DEPLOYMENT
@@ -124,11 +130,18 @@ gh run list --workflow=publish
124130
- Never run `bun publish` directly (OIDC provenance issue)
125131
- Never bump version locally
126132

133+
## CI PIPELINE
134+
135+
- **ci.yml**: Parallel test/typecheck jobs, build verification, auto-commit schema changes on master
136+
- **publish.yml**: Manual workflow_dispatch, version bump, changelog generation, OIDC npm publishing
137+
- Schema auto-commit prevents build drift
138+
- Draft release creation on dev branch
139+
127140
## NOTES
128141

129-
- **No tests**: Test framework not configured
142+
- **Testing**: Bun native test framework (`bun test`), BDD-style with `#given/#when/#then` comments
130143
- **OpenCode version**: Requires >= 1.0.150 (earlier versions have config bugs)
131-
- **Multi-language docs**: README.md (EN), README.ko.md (KO), README.ja.md (JA)
144+
- **Multi-language docs**: README.md (EN), README.ko.md (KO), README.ja.md (JA), README.zh-cn.md (ZH-CN)
132145
- **Config locations**: `~/.config/opencode/oh-my-opencode.json` (user) or `.opencode/oh-my-opencode.json` (project)
133146
- **Schema autocomplete**: Add `$schema` field in config for IDE support
134147
- **Trusted dependencies**: @ast-grep/cli, @ast-grep/napi, @code-yeongyu/comment-checker

src/features/background-agent/manager.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ import { subagentSessions } from "../claude-code-session-state"
1414

1515
type OpencodeClient = PluginInput["client"]
1616

17+
const COMPLETED_TASK_RETENTION_MS = 5 * 60 * 1000
18+
const MAX_COMPLETED_TASKS = 50
19+
1720
interface MessagePartInfo {
1821
sessionID?: string
1922
type?: string
@@ -58,12 +61,14 @@ export class BackgroundManager {
5861
private client: OpencodeClient
5962
private directory: string
6063
private pollingInterval?: Timer
64+
private cleanupTimers: Map<string, Timer>
6165

6266
constructor(ctx: PluginInput) {
6367
this.tasks = new Map()
6468
this.notifications = new Map()
6569
this.client = ctx.client
6670
this.directory = ctx.directory
71+
this.cleanupTimers = new Map()
6772
}
6873

6974
async launch(input: LaunchInput): Promise<BackgroundTask> {
@@ -130,6 +135,7 @@ export class BackgroundManager {
130135
existingTask.completedAt = new Date()
131136
this.markForNotification(existingTask)
132137
this.notifyParentSession(existingTask)
138+
this.scheduleTaskCleanup(existingTask.id)
133139
}
134140
})
135141

@@ -222,6 +228,7 @@ export class BackgroundManager {
222228
if (!task || task.status !== "running") return
223229

224230
this.checkSessionTodos(sessionID).then((hasIncompleteTodos) => {
231+
if (task.status !== "running") return
225232
if (hasIncompleteTodos) {
226233
log("[background-agent] Task has incomplete todos, waiting for todo-continuation:", task.id)
227234
return
@@ -231,6 +238,7 @@ export class BackgroundManager {
231238
task.completedAt = new Date()
232239
this.markForNotification(task)
233240
this.notifyParentSession(task)
241+
this.scheduleTaskCleanup(task.id)
234242
log("[background-agent] Task completed via session.idle event:", task.id)
235243
})
236244
}
@@ -249,6 +257,11 @@ export class BackgroundManager {
249257
task.error = "Session deleted"
250258
}
251259

260+
const cleanupTimer = this.cleanupTimers.get(task.id)
261+
if (cleanupTimer) {
262+
clearTimeout(cleanupTimer)
263+
this.cleanupTimers.delete(task.id)
264+
}
252265
this.tasks.delete(task.id)
253266
this.clearNotificationsForTask(task.id)
254267
subagentSessions.delete(sessionID)
@@ -295,6 +308,48 @@ export class BackgroundManager {
295308
}
296309
}
297310

311+
private scheduleTaskCleanup(taskId: string): void {
312+
const existingTimer = this.cleanupTimers.get(taskId)
313+
if (existingTimer) {
314+
clearTimeout(existingTimer)
315+
}
316+
317+
const timer = setTimeout(() => {
318+
this.clearNotificationsForTask(taskId)
319+
this.tasks.delete(taskId)
320+
this.cleanupTimers.delete(taskId)
321+
log("[background-agent] Cleaned up completed task after TTL:", taskId)
322+
}, COMPLETED_TASK_RETENTION_MS)
323+
timer.unref?.()
324+
325+
this.cleanupTimers.set(taskId, timer)
326+
this.enforceMaxCompletedTasks()
327+
}
328+
329+
private enforceMaxCompletedTasks(): void {
330+
const completedTasks: Array<{ id: string; completedAt: Date }> = []
331+
for (const task of this.tasks.values()) {
332+
if (task.status !== "running" && task.completedAt) {
333+
completedTasks.push({ id: task.id, completedAt: task.completedAt })
334+
}
335+
}
336+
337+
if (completedTasks.length > MAX_COMPLETED_TASKS) {
338+
completedTasks.sort((a, b) => a.completedAt.getTime() - b.completedAt.getTime())
339+
const toRemove = completedTasks.slice(0, completedTasks.length - MAX_COMPLETED_TASKS)
340+
for (const { id } of toRemove) {
341+
this.clearNotificationsForTask(id)
342+
this.tasks.delete(id)
343+
const timer = this.cleanupTimers.get(id)
344+
if (timer) {
345+
clearTimeout(timer)
346+
this.cleanupTimers.delete(id)
347+
}
348+
log("[background-agent] Evicted old completed task due to max limit:", id)
349+
}
350+
}
351+
}
352+
298353
private notifyParentSession(task: BackgroundTask): void {
299354
const duration = this.formatDuration(task.startedAt, task.completedAt)
300355

@@ -376,6 +431,7 @@ export class BackgroundManager {
376431

377432
if (sessionStatus.type === "idle") {
378433
const hasIncompleteTodos = await this.checkSessionTodos(task.sessionID)
434+
if (task.status !== "running") continue
379435
if (hasIncompleteTodos) {
380436
log("[background-agent] Task has incomplete todos via polling, waiting:", task.id)
381437
continue
@@ -385,6 +441,7 @@ export class BackgroundManager {
385441
task.completedAt = new Date()
386442
this.markForNotification(task)
387443
this.notifyParentSession(task)
444+
this.scheduleTaskCleanup(task.id)
388445
log("[background-agent] Task completed via polling:", task.id)
389446
continue
390447
}

src/hooks/claude-code-hooks/tool-input-cache.ts

Lines changed: 35 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,51 @@
1-
/**
2-
* Caches tool_input from PreToolUse for PostToolUse
3-
*/
4-
51
interface CacheEntry {
62
toolInput: Record<string, unknown>
73
timestamp: number
84
}
95

106
const cache = new Map<string, CacheEntry>()
7+
const CACHE_TTL = 60000
8+
const MAX_CACHE_SIZE = 1000
9+
10+
let cleanupInterval: ReturnType<typeof setInterval> | null = null
11+
12+
function cleanupExpiredEntries(): void {
13+
const now = Date.now()
14+
for (const [key, entry] of cache.entries()) {
15+
if (now - entry.timestamp > CACHE_TTL) {
16+
cache.delete(key)
17+
}
18+
}
19+
}
1120

12-
const CACHE_TTL = 60000 // 1 minute
21+
function startCleanupInterval(): void {
22+
if (cleanupInterval) return
23+
cleanupInterval = setInterval(cleanupExpiredEntries, CACHE_TTL)
24+
cleanupInterval.unref?.()
25+
}
26+
27+
function stopCleanupInterval(): void {
28+
if (cleanupInterval) {
29+
clearInterval(cleanupInterval)
30+
cleanupInterval = null
31+
}
32+
}
33+
34+
process.on("exit", stopCleanupInterval)
1335

1436
export function cacheToolInput(
1537
sessionId: string,
1638
toolName: string,
1739
invocationId: string,
1840
toolInput: Record<string, unknown>
1941
): void {
42+
startCleanupInterval()
43+
44+
if (cache.size >= MAX_CACHE_SIZE) {
45+
const oldestKey = cache.keys().next().value
46+
if (oldestKey) cache.delete(oldestKey)
47+
}
48+
2049
const key = `${sessionId}:${toolName}:${invocationId}`
2150
cache.set(key, { toolInput, timestamp: Date.now() })
2251
}
@@ -30,18 +59,8 @@ export function getToolInput(
3059
const entry = cache.get(key)
3160
if (!entry) return null
3261

33-
cache.delete(key)
62+
cache.delete(key)
3463
if (Date.now() - entry.timestamp > CACHE_TTL) return null
3564

3665
return entry.toolInput
3766
}
38-
39-
// Periodic cleanup (every minute)
40-
setInterval(() => {
41-
const now = Date.now()
42-
for (const [key, entry] of cache.entries()) {
43-
if (now - entry.timestamp > CACHE_TTL) {
44-
cache.delete(key)
45-
}
46-
}
47-
}, CACHE_TTL)

src/hooks/comment-checker/index.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ const pendingCalls = new Map<string, PendingCall>()
2020
const PENDING_CALL_TTL = 60_000
2121

2222
let cliPathPromise: Promise<string | null> | null = null
23+
let cleanupInterval: ReturnType<typeof setInterval> | null = null
2324

2425
function cleanupOldPendingCalls(): void {
2526
const now = Date.now()
@@ -30,12 +31,25 @@ function cleanupOldPendingCalls(): void {
3031
}
3132
}
3233

33-
setInterval(cleanupOldPendingCalls, 10_000)
34+
function startCleanupInterval(): void {
35+
if (cleanupInterval) return
36+
cleanupInterval = setInterval(cleanupOldPendingCalls, 10_000)
37+
cleanupInterval.unref?.()
38+
}
39+
40+
function stopCleanupInterval(): void {
41+
if (cleanupInterval) {
42+
clearInterval(cleanupInterval)
43+
cleanupInterval = null
44+
}
45+
}
46+
47+
process.on("exit", stopCleanupInterval)
3448

3549
export function createCommentCheckerHooks() {
3650
debugLog("createCommentCheckerHooks called")
3751

38-
// Start background CLI initialization (may trigger lazy download)
52+
startCleanupInterval()
3953
startBackgroundInit()
4054
cliPathPromise = getCommentCheckerPath()
4155
cliPathPromise.then(path => {

0 commit comments

Comments
 (0)