Merge remote-tracking branch 'origin/dev' into refactor/modular-code-enforcement

# Conflicts:
#	src/agents/utils.ts
#	src/config/schema.ts
#	src/features/background-agent/spawner/background-session-creator.ts
#	src/features/background-agent/spawner/parent-directory-resolver.ts
#	src/features/background-agent/spawner/tmux-callback-invoker.ts
#	src/features/tmux-subagent/manager.ts
#	src/hooks/interactive-bash-session/index.ts
#	src/hooks/task-continuation-enforcer.test.ts
#	src/index.ts
#	src/plugin-handlers/config-handler.test.ts
#	src/tools/background-task/tools.ts
#	src/tools/call-omo-agent/tools.ts
#	src/tools/delegate-task/executor.ts
This commit is contained in:
YeonGyu-Kim
2026-02-08 19:05:41 +09:00
47 changed files with 2767 additions and 1376 deletions

View File

@@ -1,7 +1,7 @@
# PROJECT KNOWLEDGE BASE
**Generated:** 2026-02-06T18:30:00+09:00
**Commit:** c6c149e
**Generated:** 2026-02-08T16:45:00+09:00
**Commit:** edee865f
**Branch:** dev
---
@@ -135,8 +135,8 @@ oh-my-opencode/
│ ├── cli/ # CLI installer, doctor - see src/cli/AGENTS.md
│ ├── mcp/ # Built-in MCPs - see src/mcp/AGENTS.md
│ ├── config/ # Zod schema (schema.ts 455 lines), TypeScript types
│ ├── plugin-handlers/ # Plugin config loading (config-handler.ts 501 lines)
│ ├── index.ts # Main plugin entry (924 lines)
│ ├── plugin-handlers/ # Plugin config loading (config-handler.ts 562 lines)
│ ├── index.ts # Main plugin entry (999 lines)
│ ├── plugin-config.ts # Config loading orchestration
│ └── plugin-state.ts # Model cache state
├── script/ # build-schema.ts, build-binaries.ts, publish.ts
@@ -170,7 +170,7 @@ oh-my-opencode/
**Rules:**
- NEVER write implementation before test
- NEVER delete failing tests - fix the code
- Test file: `*.test.ts` alongside source (100+ test files)
- Test file: `*.test.ts` alongside source (163 test files)
- BDD comments: `//#given`, `//#when`, `//#then`
## CONVENTIONS
@@ -180,7 +180,7 @@ oh-my-opencode/
- **Build**: `bun build` (ESM) + `tsc --emitDeclarationOnly`
- **Exports**: Barrel pattern via index.ts
- **Naming**: kebab-case dirs, `createXXXHook`/`createXXXTool` factories
- **Testing**: BDD comments, 100+ test files
- **Testing**: BDD comments, 163 test files
- **Temperature**: 0.1 for code agents, max 0.3
## ANTI-PATTERNS
@@ -241,19 +241,22 @@ bun test # 100+ test files
| File | Lines | Description |
|------|-------|-------------|
| `src/features/background-agent/manager.ts` | 1556 | Task lifecycle, concurrency |
| `src/features/background-agent/manager.ts` | 1642 | Task lifecycle, concurrency |
| `src/features/builtin-skills/skills/git-master.ts` | 1107 | Git master skill definition |
| `src/tools/delegate-task/executor.ts` | 983 | Category-based delegation executor |
| `src/index.ts` | 924 | Main plugin entry |
| `src/tools/lsp/client.ts` | 803 | LSP client operations |
| `src/hooks/atlas/index.ts` | 770 | Orchestrator hook |
| `src/tools/background-task/tools.ts` | 734 | Background task tools |
| `src/index.ts` | 999 | Main plugin entry |
| `src/tools/delegate-task/executor.ts` | 969 | Category-based delegation executor |
| `src/tools/lsp/client.ts` | 851 | LSP client operations |
| `src/tools/background-task/tools.ts` | 757 | Background task tools |
| `src/hooks/atlas/index.ts` | 697 | Orchestrator hook |
| `src/cli/config-manager.ts` | 667 | JSONC config parsing |
| `src/features/skill-mcp-manager/manager.ts` | 640 | MCP client lifecycle |
| `src/features/builtin-commands/templates/refactor.ts` | 619 | Refactor command template |
| `src/agents/hephaestus.ts` | 618 | Autonomous deep worker agent |
| `src/agents/utils.ts` | 571 | Agent creation, model fallback resolution |
| `src/plugin-handlers/config-handler.ts` | 562 | Plugin config loading |
| `src/tools/delegate-task/constants.ts` | 552 | Delegation constants |
| `src/cli/install.ts` | 542 | Interactive CLI installer |
| `src/hooks/task-continuation-enforcer.ts` | 530 | Task completion enforcement |
| `src/agents/sisyphus.ts` | 530 | Main orchestrator agent |
## MCP ARCHITECTURE

128
src/AGENTS.md Normal file
View File

@@ -0,0 +1,128 @@
# AGENTS KNOWLEDGE BASE
## OVERVIEW
Main plugin entry point and orchestration layer. 1000+ lines of plugin initialization, hook registration, tool composition, and lifecycle management.
**Core Responsibilities:**
- Plugin initialization and configuration loading
- 40+ lifecycle hooks orchestration
- 25+ tools composition and filtering
- Background agent management
- Session state coordination
- MCP server lifecycle
- Tmux integration
- Claude Code compatibility layer
## STRUCTURE
```
src/
├── index.ts # Main plugin entry (1000 lines) - orchestration layer
├── index.compaction-model-agnostic.static.test.ts # Compaction hook tests
├── agents/ # 11 AI agents (16 files)
├── cli/ # CLI commands (9 files)
├── config/ # Schema validation (3 files)
├── features/ # Background features (20+ files)
├── hooks/ # 40+ lifecycle hooks (14 files)
├── mcp/ # MCP server configs (7 files)
├── plugin-handlers/ # Config loading (3 files)
├── shared/ # Utilities (70 files)
└── tools/ # 25+ tools (15 files)
```
## KEY COMPONENTS
**Plugin Initialization:**
- `OhMyOpenCodePlugin()`: Main plugin factory (lines 124-841)
- Configuration loading via `loadPluginConfig()`
- Hook registration with safe creation patterns
- Tool composition and disabled tool filtering
**Lifecycle Management:**
- 40+ hooks: session recovery, continuation enforcers, compaction, context injection
- Background agent coordination via `BackgroundManager`
- Tmux session management for multi-pane workflows
- MCP server lifecycle via `SkillMcpManager`
**Tool Ecosystem:**
- 25+ tools: LSP, AST-grep, delegation, background tasks, skills
- Tool filtering based on agent permissions and user config
- Metadata restoration for tool outputs
**Integration Points:**
- Claude Code compatibility hooks and commands
- OpenCode SDK client interactions
- Session state persistence and recovery
- Model variant resolution and application
## HOOK REGISTRATION PATTERNS
**Safe Hook Creation:**
```typescript
const hook = isHookEnabled("hook-name")
? safeCreateHook("hook-name", () => createHookFactory(ctx), { enabled: safeHookEnabled })
: null;
```
**Hook Categories:**
- **Session Management**: recovery, notification, compaction
- **Continuation**: todo/task enforcers, stop guards
- **Context**: injection, rules, directory content
- **Tool Enhancement**: output truncation, error recovery, validation
- **Agent Coordination**: usage reminders, babysitting, delegation
## TOOL COMPOSITION
**Core Tools:**
```typescript
const allTools: Record<string, ToolDefinition> = {
...builtinTools, // Basic file/session operations
...createGrepTools(ctx), // Content search
...createAstGrepTools(ctx), // AST-aware refactoring
task: delegateTask, // Agent delegation
skill: skillTool, // Skill execution
// ... 20+ more tools
};
```
**Tool Filtering:**
- Agent permission-based restrictions
- User-configured disabled tools
- Dynamic tool availability based on session state
## SESSION LIFECYCLE
**Session Events:**
- `session.created`: Initialize session state, tmux setup
- `session.deleted`: Cleanup resources, clear caches
- `message.updated`: Update agent assignments
- `session.error`: Trigger recovery mechanisms
**Continuation Flow:**
1. User message triggers agent selection
2. Model/variant resolution applied
3. Tools execute with hook interception
4. Continuation enforcers monitor completion
5. Session compaction preserves context
## CONFIGURATION INTEGRATION
**Plugin Config Loading:**
- Project + user config merging
- Schema validation via Zod
- Migration support for legacy configs
- Dynamic feature enablement
**Runtime Configuration:**
- Hook enablement based on `disabled_hooks`
- Tool filtering via `disabled_tools`
- Agent overrides and category definitions
- Experimental feature toggles
## ANTI-PATTERNS
- **Direct hook exports**: All hooks created via factories for testability
- **Global state pollution**: Session-scoped state management
- **Synchronous blocking**: Async-first architecture with background coordination
- **Tight coupling**: Plugin components communicate via events, not direct calls
- **Memory leaks**: Proper cleanup on session deletion and plugin unload

View File

@@ -2,7 +2,7 @@
## OVERVIEW
11 AI agents for multi-model orchestration. Each agent has factory function + metadata + fallback chains.
32 files containing AI agents and utilities for multi-model orchestration. Each agent has factory function + metadata + fallback chains.
**Primary Agents** (respect UI model selection):
- Sisyphus, Atlas, Prometheus

View File

@@ -1209,4 +1209,29 @@ describe("Deadlock prevention - fetchAvailableModels must not receive client", (
fetchSpy.mockRestore?.()
cacheSpy.mockRestore?.()
})
test("Hephaestus variant override respects user config over hardcoded default", async () => {
// #given - user provides variant in config
const overrides = {
hephaestus: { variant: "high" },
}
// #when
const agents = await createBuiltinAgents([], overrides, undefined, TEST_DEFAULT_MODEL)
// #then - user variant takes precedence over hardcoded "medium"
expect(agents.hephaestus).toBeDefined()
expect(agents.hephaestus.variant).toBe("high")
})
test("Hephaestus uses default variant when no user override provided", async () => {
// #given - no variant override in config
const overrides = {}
// #when
const agents = await createBuiltinAgents([], overrides, undefined, TEST_DEFAULT_MODEL)
// #then - default "medium" variant is applied
expect(agents.hephaestus).toBeDefined()
expect(agents.hephaestus.variant).toBe("medium")
})
})

View File

@@ -2,7 +2,7 @@
## OVERVIEW
CLI entry: `bunx oh-my-opencode`. 5 commands with Commander.js + @clack/prompts TUI.
CLI entry: `bunx oh-my-opencode`. 70 CLI utilities and commands with Commander.js + @clack/prompts TUI.
**Commands**: install (interactive setup), doctor (14 health checks), run (session launcher), get-local-version, mcp-oauth

93
src/config/AGENTS.md Normal file
View File

@@ -0,0 +1,93 @@
**Generated:** 2026-02-08T16:45:00+09:00
**Commit:** f2b7b759
**Branch:** dev
## OVERVIEW
Zod schema definitions for plugin configuration. 455+ lines of type-safe config validation with JSONC support, multi-level inheritance, and comprehensive agent/category overrides.
## STRUCTURE
```
config/
├── schema.ts # Main Zod schema (455 lines) - agents, categories, experimental features
├── schema.test.ts # Schema validation tests (17909 lines)
└── index.ts # Barrel export
```
## SCHEMA COMPONENTS
**Agent Configuration:**
- `AgentOverrideConfigSchema`: Model, variant, temperature, permissions, tools
- `AgentOverridesSchema`: Per-agent overrides (sisyphus, hephaestus, prometheus, etc.)
- `AgentPermissionSchema`: Tool access control (edit, bash, webfetch, task)
**Category Configuration:**
- `CategoryConfigSchema`: Model defaults, thinking budgets, tool restrictions
- `CategoriesConfigSchema`: Named categories (visual-engineering, ultrabrain, deep, etc.)
**Experimental Features:**
- `ExperimentalConfigSchema`: Dynamic context pruning, task system, plugin timeouts
- `DynamicContextPruningConfigSchema`: Intelligent context management
**Built-in Enums:**
- `AgentNameSchema`: sisyphus, hephaestus, prometheus, oracle, librarian, explore, multimodal-looker, metis, momus, atlas
- `HookNameSchema`: 100+ hook names for lifecycle management
- `BuiltinCommandNameSchema`: init-deep, ralph-loop, refactor, start-work
- `BuiltinSkillNameSchema`: playwright, agent-browser, git-master
## CONFIGURATION HIERARCHY
1. **Project config** (`.opencode/oh-my-opencode.json`)
2. **User config** (`~/.config/opencode/oh-my-opencode.json`)
3. **Defaults** (hardcoded fallbacks)
**Multi-level inheritance:** Project → User → Defaults
## VALIDATION FEATURES
- **JSONC support**: Comments and trailing commas
- **Type safety**: Full TypeScript inference
- **Migration support**: Legacy config compatibility
- **Schema versioning**: $schema field for validation
## KEY SCHEMAS
| Schema | Purpose | Lines |
|--------|---------|-------|
| `OhMyOpenCodeConfigSchema` | Root config schema | 400+ |
| `AgentOverrideConfigSchema` | Agent customization | 50+ |
| `CategoryConfigSchema` | Task category defaults | 30+ |
| `ExperimentalConfigSchema` | Beta features | 40+ |
## USAGE PATTERNS
**Agent Override:**
```typescript
agents: {
sisyphus: {
model: "anthropic/claude-opus-4-6",
variant: "max",
temperature: 0.1
}
}
```
**Category Definition:**
```typescript
categories: {
"visual-engineering": {
model: "google/gemini-3-pro",
variant: "high"
}
}
```
**Experimental Features:**
```typescript
experimental: {
dynamic_context_pruning: {
enabled: true,
notification: "detailed"
}
}
```

View File

@@ -2,61 +2,29 @@
## OVERVIEW
17 feature modules: background agents, skill MCPs, builtin skills/commands, Claude Code compatibility layer, task management.
**Feature Types**: Task orchestration, Skill definitions, Command templates, Claude Code loaders, Supporting utilities
Background agents, skills, Claude Code compat, builtin commands, MCP managers, etc.
## STRUCTURE
```
features/
├── background-agent/ # Task lifecycle (1556 lines)
│ ├── manager.ts # Launch → poll → complete
│ └── concurrency.ts # Per-provider limits
├── builtin-skills/ # Core skills
│ └── skills/ # playwright, agent-browser, frontend-ui-ux, git-master, dev-browser
├── builtin-commands/ # ralph-loop, refactor, ulw-loop, init-deep, start-work, cancel-ralph, stop-continuation
├── claude-code-agent-loader/ # ~/.claude/agents/*.md
├── claude-code-command-loader/ # ~/.claude/commands/*.md
├── claude-code-mcp-loader/ # .mcp.json with ${VAR} expansion
├── claude-code-plugin-loader/ # installed_plugins.json (486 lines)
├── claude-code-session-state/ # Session persistence
├── opencode-skill-loader/ # Skills from 6 directories (loader.ts 311 lines)
├── context-injector/ # AGENTS.md/README.md injection
├── boulder-state/ # Todo state persistence
├── hook-message-injector/ # Message injection
├── task-toast-manager/ # Background task notifications
├── skill-mcp-manager/ # MCP client lifecycle (640 lines)
├── tmux-subagent/ # Tmux session management (472 lines)
├── mcp-oauth/ # MCP OAuth handling
└── claude-tasks/ # Task schema/storage - see AGENTS.md
```
├── background-agent/ # Task lifecycle, concurrency (manager.ts 1642 lines)
├── builtin-skills/ # Skills like git-master (1107 lines)
├── builtin-commands/ # Commands like refactor (619 lines)
├── skill-mcp-manager/ # MCP client lifecycle (640 lines)
├── claude-code-plugin-loader/ # Plugin loading
├── claude-code-mcp-loader/ # MCP loading
├── claude-code-session-state/ # Session state
├── claude-code-command-loader/ # Command loading
├── claude-code-agent-loader/ # Agent loading
├── context-injector/ # Context injection
├── hook-message-injector/ # Message injection
├── task-toast-manager/ # Task toasts
├── boulder-state/ # State management
├── tmux-subagent/ # Tmux subagent
├── mcp-oauth/ # OAuth for MCP
├── opencode-skill-loader/ # Skill loading
├── tool-metadata-store/ # Tool metadata
## LOADER PRIORITY
## HOW TO ADD
| Type | Priority (highest first) |
|------|--------------------------|
| Commands | `.opencode/command/` > `~/.config/opencode/command/` > `.claude/commands/` |
| Skills | `.opencode/skills/` > `~/.config/opencode/skills/` > `.claude/skills/` |
| MCPs | `.claude/.mcp.json` > `.mcp.json` > `~/.claude/.mcp.json` |
## BACKGROUND AGENT
- **Lifecycle**: `launch``poll` (2s) → `complete`
- **Stability**: 3 consecutive polls = idle
- **Concurrency**: Per-provider/model limits via `ConcurrencyManager`
- **Cleanup**: 30m TTL, 3m stale timeout
- **State**: Per-session Maps, cleaned on `session.deleted`
## SKILL MCP
- **Lazy**: Clients created on first call
- **Transports**: stdio, http (SSE/Streamable)
- **Lifecycle**: 5m idle cleanup
## ANTI-PATTERNS
- **Sequential delegation**: Use `task` parallel
- **Trust self-reports**: ALWAYS verify
- **Main thread blocks**: No heavy I/O in loader init
- **Direct state mutation**: Use managers for boulder/session state
Create dir with index.ts, types.ts, etc.

View File

@@ -15,6 +15,7 @@ export async function createBackgroundSession(options: {
const body = {
parentID: input.parentSessionID,
title: `Background: ${input.description}`,
permission: [{ permission: "question", action: "deny" as const, pattern: "*" }],
}
const createResult = await client.session

View File

@@ -1,2 +1,3 @@
export * from "./types"
export * from "./storage"
export * from "./session-storage"

View File

@@ -0,0 +1,204 @@
import { describe, test, expect, beforeEach, afterEach } from "bun:test"
import { existsSync, mkdirSync, rmSync, writeFileSync, readdirSync } from "fs"
import { join } from "path"
import type { OhMyOpenCodeConfig } from "../../config/schema"
import {
getSessionTaskDir,
listSessionTaskFiles,
listAllSessionDirs,
findTaskAcrossSessions,
} from "./session-storage"
const TEST_DIR = ".test-session-storage"
const TEST_DIR_ABS = join(process.cwd(), TEST_DIR)
function makeConfig(storagePath: string): Partial<OhMyOpenCodeConfig> {
return {
sisyphus: {
tasks: { storage_path: storagePath, claude_code_compat: false },
},
}
}
describe("getSessionTaskDir", () => {
test("returns session-scoped subdirectory under base task dir", () => {
//#given
const config = makeConfig("/tmp/tasks")
const sessionID = "ses_abc123"
//#when
const result = getSessionTaskDir(config, sessionID)
//#then
expect(result).toBe("/tmp/tasks/ses_abc123")
})
test("uses relative storage path joined with cwd", () => {
//#given
const config = makeConfig(TEST_DIR)
const sessionID = "ses_xyz"
//#when
const result = getSessionTaskDir(config, sessionID)
//#then
expect(result).toBe(join(TEST_DIR_ABS, "ses_xyz"))
})
})
describe("listSessionTaskFiles", () => {
beforeEach(() => {
if (existsSync(TEST_DIR_ABS)) {
rmSync(TEST_DIR_ABS, { recursive: true, force: true })
}
})
afterEach(() => {
if (existsSync(TEST_DIR_ABS)) {
rmSync(TEST_DIR_ABS, { recursive: true, force: true })
}
})
test("returns empty array when session directory does not exist", () => {
//#given
const config = makeConfig(TEST_DIR)
//#when
const result = listSessionTaskFiles(config, "nonexistent-session")
//#then
expect(result).toEqual([])
})
test("lists only T-*.json files in the session directory", () => {
//#given
const config = makeConfig(TEST_DIR)
const sessionDir = join(TEST_DIR_ABS, "ses_001")
mkdirSync(sessionDir, { recursive: true })
writeFileSync(join(sessionDir, "T-aaa.json"), "{}", "utf-8")
writeFileSync(join(sessionDir, "T-bbb.json"), "{}", "utf-8")
writeFileSync(join(sessionDir, "other.txt"), "nope", "utf-8")
//#when
const result = listSessionTaskFiles(config, "ses_001")
//#then
expect(result).toHaveLength(2)
expect(result).toContain("T-aaa")
expect(result).toContain("T-bbb")
})
test("does not list tasks from other sessions", () => {
//#given
const config = makeConfig(TEST_DIR)
const session1Dir = join(TEST_DIR_ABS, "ses_001")
const session2Dir = join(TEST_DIR_ABS, "ses_002")
mkdirSync(session1Dir, { recursive: true })
mkdirSync(session2Dir, { recursive: true })
writeFileSync(join(session1Dir, "T-from-s1.json"), "{}", "utf-8")
writeFileSync(join(session2Dir, "T-from-s2.json"), "{}", "utf-8")
//#when
const result = listSessionTaskFiles(config, "ses_001")
//#then
expect(result).toEqual(["T-from-s1"])
})
})
describe("listAllSessionDirs", () => {
beforeEach(() => {
if (existsSync(TEST_DIR_ABS)) {
rmSync(TEST_DIR_ABS, { recursive: true, force: true })
}
})
afterEach(() => {
if (existsSync(TEST_DIR_ABS)) {
rmSync(TEST_DIR_ABS, { recursive: true, force: true })
}
})
test("returns empty array when base directory does not exist", () => {
//#given
const config = makeConfig(TEST_DIR)
//#when
const result = listAllSessionDirs(config)
//#then
expect(result).toEqual([])
})
test("returns only directory entries (not files)", () => {
//#given
const config = makeConfig(TEST_DIR)
mkdirSync(TEST_DIR_ABS, { recursive: true })
mkdirSync(join(TEST_DIR_ABS, "ses_001"), { recursive: true })
mkdirSync(join(TEST_DIR_ABS, "ses_002"), { recursive: true })
writeFileSync(join(TEST_DIR_ABS, ".lock"), "{}", "utf-8")
writeFileSync(join(TEST_DIR_ABS, "T-legacy.json"), "{}", "utf-8")
//#when
const result = listAllSessionDirs(config)
//#then
expect(result).toHaveLength(2)
expect(result).toContain("ses_001")
expect(result).toContain("ses_002")
})
})
describe("findTaskAcrossSessions", () => {
beforeEach(() => {
if (existsSync(TEST_DIR_ABS)) {
rmSync(TEST_DIR_ABS, { recursive: true, force: true })
}
})
afterEach(() => {
if (existsSync(TEST_DIR_ABS)) {
rmSync(TEST_DIR_ABS, { recursive: true, force: true })
}
})
test("returns null when task does not exist in any session", () => {
//#given
const config = makeConfig(TEST_DIR)
mkdirSync(join(TEST_DIR_ABS, "ses_001"), { recursive: true })
//#when
const result = findTaskAcrossSessions(config, "T-nonexistent")
//#then
expect(result).toBeNull()
})
test("finds task in the correct session directory", () => {
//#given
const config = makeConfig(TEST_DIR)
const session2Dir = join(TEST_DIR_ABS, "ses_002")
mkdirSync(join(TEST_DIR_ABS, "ses_001"), { recursive: true })
mkdirSync(session2Dir, { recursive: true })
writeFileSync(join(session2Dir, "T-target.json"), '{"id":"T-target"}', "utf-8")
//#when
const result = findTaskAcrossSessions(config, "T-target")
//#then
expect(result).not.toBeNull()
expect(result!.sessionID).toBe("ses_002")
expect(result!.path).toBe(join(session2Dir, "T-target.json"))
})
test("returns null when base directory does not exist", () => {
//#given
const config = makeConfig(TEST_DIR)
//#when
const result = findTaskAcrossSessions(config, "T-any")
//#then
expect(result).toBeNull()
})
})

View File

@@ -0,0 +1,52 @@
import { join } from "path"
import { existsSync, readdirSync, statSync } from "fs"
import { getTaskDir } from "./storage"
import type { OhMyOpenCodeConfig } from "../../config/schema"
export function getSessionTaskDir(
config: Partial<OhMyOpenCodeConfig>,
sessionID: string,
): string {
return join(getTaskDir(config), sessionID)
}
export function listSessionTaskFiles(
config: Partial<OhMyOpenCodeConfig>,
sessionID: string,
): string[] {
const dir = getSessionTaskDir(config, sessionID)
if (!existsSync(dir)) return []
return readdirSync(dir)
.filter((f) => f.endsWith(".json") && f.startsWith("T-"))
.map((f) => f.replace(".json", ""))
}
export function listAllSessionDirs(
config: Partial<OhMyOpenCodeConfig>,
): string[] {
const baseDir = getTaskDir(config)
if (!existsSync(baseDir)) return []
return readdirSync(baseDir).filter((entry) => {
const fullPath = join(baseDir, entry)
return statSync(fullPath).isDirectory()
})
}
export interface TaskLocation {
path: string
sessionID: string
}
export function findTaskAcrossSessions(
config: Partial<OhMyOpenCodeConfig>,
taskId: string,
): TaskLocation | null {
const sessionDirs = listAllSessionDirs(config)
for (const sessionID of sessionDirs) {
const taskPath = join(getSessionTaskDir(config, sessionID), `${taskId}.json`)
if (existsSync(taskPath)) {
return { path: taskPath, sessionID }
}
}
return null
}

View File

@@ -20,6 +20,7 @@ const TEST_DIR_ABS = join(process.cwd(), TEST_DIR)
describe("getTaskDir", () => {
const originalTaskListId = process.env.ULTRAWORK_TASK_LIST_ID
const originalClaudeTaskListId = process.env.CLAUDE_CODE_TASK_LIST_ID
beforeEach(() => {
if (originalTaskListId === undefined) {
@@ -27,6 +28,12 @@ describe("getTaskDir", () => {
} else {
process.env.ULTRAWORK_TASK_LIST_ID = originalTaskListId
}
if (originalClaudeTaskListId === undefined) {
delete process.env.CLAUDE_CODE_TASK_LIST_ID
} else {
process.env.CLAUDE_CODE_TASK_LIST_ID = originalClaudeTaskListId
}
})
afterEach(() => {
@@ -35,6 +42,12 @@ describe("getTaskDir", () => {
} else {
process.env.ULTRAWORK_TASK_LIST_ID = originalTaskListId
}
if (originalClaudeTaskListId === undefined) {
delete process.env.CLAUDE_CODE_TASK_LIST_ID
} else {
process.env.CLAUDE_CODE_TASK_LIST_ID = originalClaudeTaskListId
}
})
test("returns global config path for default config", () => {
@@ -62,6 +75,19 @@ describe("getTaskDir", () => {
expect(result).toBe(join(configDir, "tasks", "custom-list-id"))
})
test("respects CLAUDE_CODE_TASK_LIST_ID env var when ULTRAWORK_TASK_LIST_ID not set", () => {
//#given
delete process.env.ULTRAWORK_TASK_LIST_ID
process.env.CLAUDE_CODE_TASK_LIST_ID = "claude list/id"
const configDir = getOpenCodeConfigDir({ binary: "opencode" })
//#when
const result = getTaskDir()
//#then
expect(result).toBe(join(configDir, "tasks", "claude-list-id"))
})
test("falls back to sanitized cwd basename when env var not set", () => {
//#given
delete process.env.ULTRAWORK_TASK_LIST_ID
@@ -114,6 +140,7 @@ describe("getTaskDir", () => {
describe("resolveTaskListId", () => {
const originalTaskListId = process.env.ULTRAWORK_TASK_LIST_ID
const originalClaudeTaskListId = process.env.CLAUDE_CODE_TASK_LIST_ID
beforeEach(() => {
if (originalTaskListId === undefined) {
@@ -121,6 +148,12 @@ describe("resolveTaskListId", () => {
} else {
process.env.ULTRAWORK_TASK_LIST_ID = originalTaskListId
}
if (originalClaudeTaskListId === undefined) {
delete process.env.CLAUDE_CODE_TASK_LIST_ID
} else {
process.env.CLAUDE_CODE_TASK_LIST_ID = originalClaudeTaskListId
}
})
afterEach(() => {
@@ -129,6 +162,12 @@ describe("resolveTaskListId", () => {
} else {
process.env.ULTRAWORK_TASK_LIST_ID = originalTaskListId
}
if (originalClaudeTaskListId === undefined) {
delete process.env.CLAUDE_CODE_TASK_LIST_ID
} else {
process.env.CLAUDE_CODE_TASK_LIST_ID = originalClaudeTaskListId
}
})
test("returns env var when set", () => {
@@ -142,6 +181,30 @@ describe("resolveTaskListId", () => {
expect(result).toBe("custom-list")
})
test("returns CLAUDE_CODE_TASK_LIST_ID when ULTRAWORK_TASK_LIST_ID not set", () => {
//#given
delete process.env.ULTRAWORK_TASK_LIST_ID
process.env.CLAUDE_CODE_TASK_LIST_ID = "claude-list"
//#when
const result = resolveTaskListId()
//#then
expect(result).toBe("claude-list")
})
test("sanitizes CLAUDE_CODE_TASK_LIST_ID special characters", () => {
//#given
delete process.env.ULTRAWORK_TASK_LIST_ID
process.env.CLAUDE_CODE_TASK_LIST_ID = "claude list/id"
//#when
const result = resolveTaskListId()
//#then
expect(result).toBe("claude-list-id")
})
test("sanitizes special characters", () => {
//#given
process.env.ULTRAWORK_TASK_LIST_ID = "custom list/id"

View File

@@ -0,0 +1,43 @@
import type { TmuxConfig } from "../../config/schema"
import type { TrackedSession } from "./types"
import { log } from "../../shared"
import { queryWindowState } from "./pane-state-querier"
import { executeAction } from "./action-executor"
import { TmuxPollingManager } from "./polling-manager"
export class ManagerCleanup {
constructor(
private sessions: Map<string, TrackedSession>,
private sourcePaneId: string | undefined,
private pollingManager: TmuxPollingManager,
private tmuxConfig: TmuxConfig,
private serverUrl: string
) {}
async cleanup(): Promise<void> {
this.pollingManager.stopPolling()
if (this.sessions.size > 0) {
log("[tmux-session-manager] closing all panes", { count: this.sessions.size })
const state = this.sourcePaneId ? await queryWindowState(this.sourcePaneId) : null
if (state) {
const closePromises = Array.from(this.sessions.values()).map((s) =>
executeAction(
{ type: "close", paneId: s.paneId, sessionId: s.sessionId },
{ config: this.tmuxConfig, serverUrl: this.serverUrl, windowState: state }
).catch((err) =>
log("[tmux-session-manager] cleanup error for pane", {
paneId: s.paneId,
error: String(err),
}),
),
)
await Promise.all(closePromises)
}
this.sessions.clear()
}
log("[tmux-session-manager] cleanup complete")
}
}

View File

@@ -0,0 +1,139 @@
import type { OpencodeClient } from "../../tools/delegate-task/types"
import { POLL_INTERVAL_BACKGROUND_MS } from "../../shared/tmux"
import type { TrackedSession } from "./types"
import { SESSION_MISSING_GRACE_MS } from "../../shared/tmux"
import { log } from "../../shared"
const SESSION_TIMEOUT_MS = 10 * 60 * 1000
const MIN_STABILITY_TIME_MS = 10 * 1000
const STABLE_POLLS_REQUIRED = 3
export class TmuxPollingManager {
private pollInterval?: ReturnType<typeof setInterval>
constructor(
private client: OpencodeClient,
private sessions: Map<string, TrackedSession>,
private closeSessionById: (sessionId: string) => Promise<void>
) {}
startPolling(): void {
if (this.pollInterval) return
this.pollInterval = setInterval(
() => this.pollSessions(),
POLL_INTERVAL_BACKGROUND_MS, // POLL_INTERVAL_BACKGROUND_MS
)
log("[tmux-session-manager] polling started")
}
stopPolling(): void {
if (this.pollInterval) {
clearInterval(this.pollInterval)
this.pollInterval = undefined
log("[tmux-session-manager] polling stopped")
}
}
private async pollSessions(): Promise<void> {
if (this.sessions.size === 0) {
this.stopPolling()
return
}
try {
const statusResult = await this.client.session.status({ path: undefined })
const allStatuses = (statusResult.data ?? {}) as Record<string, { type: string }>
log("[tmux-session-manager] pollSessions", {
trackedSessions: Array.from(this.sessions.keys()),
allStatusKeys: Object.keys(allStatuses),
})
const now = Date.now()
const sessionsToClose: string[] = []
for (const [sessionId, tracked] of this.sessions.entries()) {
const status = allStatuses[sessionId]
const isIdle = status?.type === "idle"
if (status) {
tracked.lastSeenAt = new Date(now)
}
const missingSince = !status ? now - tracked.lastSeenAt.getTime() : 0
const missingTooLong = missingSince >= SESSION_MISSING_GRACE_MS
const isTimedOut = now - tracked.createdAt.getTime() > SESSION_TIMEOUT_MS
const elapsedMs = now - tracked.createdAt.getTime()
let shouldCloseViaStability = false
if (isIdle && elapsedMs >= MIN_STABILITY_TIME_MS) {
try {
const messagesResult = await this.client.session.messages({
path: { id: sessionId }
})
const currentMsgCount = Array.isArray(messagesResult.data)
? messagesResult.data.length
: 0
if (tracked.lastMessageCount === currentMsgCount) {
tracked.stableIdlePolls = (tracked.stableIdlePolls ?? 0) + 1
if (tracked.stableIdlePolls >= STABLE_POLLS_REQUIRED) {
const recheckResult = await this.client.session.status({ path: undefined })
const recheckStatuses = (recheckResult.data ?? {}) as Record<string, { type: string }>
const recheckStatus = recheckStatuses[sessionId]
if (recheckStatus?.type === "idle") {
shouldCloseViaStability = true
} else {
tracked.stableIdlePolls = 0
log("[tmux-session-manager] stability reached but session not idle on recheck, resetting", {
sessionId,
recheckStatus: recheckStatus?.type,
})
}
}
} else {
tracked.stableIdlePolls = 0
}
tracked.lastMessageCount = currentMsgCount
} catch (msgErr) {
log("[tmux-session-manager] failed to fetch messages for stability check", {
sessionId,
error: String(msgErr),
})
}
} else if (!isIdle) {
tracked.stableIdlePolls = 0
}
log("[tmux-session-manager] session check", {
sessionId,
statusType: status?.type,
isIdle,
elapsedMs,
stableIdlePolls: tracked.stableIdlePolls,
lastMessageCount: tracked.lastMessageCount,
missingSince,
missingTooLong,
isTimedOut,
shouldCloseViaStability,
})
if (shouldCloseViaStability || missingTooLong || isTimedOut) {
sessionsToClose.push(sessionId)
}
}
for (const sessionId of sessionsToClose) {
log("[tmux-session-manager] closing session due to poll", { sessionId })
await this.closeSessionById(sessionId)
}
} catch (err) {
log("[tmux-session-manager] poll error", { error: String(err) })
}
}
}

View File

@@ -0,0 +1,80 @@
import type { TmuxConfig } from "../../config/schema"
import type { TrackedSession } from "./types"
import type { SessionMapping } from "./decision-engine"
import { log } from "../../shared"
import { queryWindowState } from "./pane-state-querier"
import { decideCloseAction } from "./decision-engine"
import { executeAction } from "./action-executor"
import { TmuxPollingManager } from "./polling-manager"
export interface TmuxUtilDeps {
isInsideTmux: () => boolean
getCurrentPaneId: () => string | undefined
}
export class SessionCleaner {
constructor(
private tmuxConfig: TmuxConfig,
private deps: TmuxUtilDeps,
private sessions: Map<string, TrackedSession>,
private sourcePaneId: string | undefined,
private getSessionMappings: () => SessionMapping[],
private pollingManager: TmuxPollingManager,
private serverUrl: string
) {}
private isEnabled(): boolean {
return this.tmuxConfig.enabled && this.deps.isInsideTmux()
}
async onSessionDeleted(event: { sessionID: string }): Promise<void> {
if (!this.isEnabled()) return
if (!this.sourcePaneId) return
const tracked = this.sessions.get(event.sessionID)
if (!tracked) return
log("[tmux-session-manager] onSessionDeleted", { sessionId: event.sessionID })
const state = await queryWindowState(this.sourcePaneId)
if (!state) {
this.sessions.delete(event.sessionID)
return
}
const closeAction = decideCloseAction(state, event.sessionID, this.getSessionMappings())
if (closeAction) {
await executeAction(closeAction, { config: this.tmuxConfig, serverUrl: this.serverUrl, windowState: state })
}
this.sessions.delete(event.sessionID)
if (this.sessions.size === 0) {
this.pollingManager.stopPolling()
}
}
async closeSessionById(sessionId: string): Promise<void> {
const tracked = this.sessions.get(sessionId)
if (!tracked) return
log("[tmux-session-manager] closing session pane", {
sessionId,
paneId: tracked.paneId,
})
const state = this.sourcePaneId ? await queryWindowState(this.sourcePaneId) : null
if (state) {
await executeAction(
{ type: "close", paneId: tracked.paneId, sessionId },
{ config: this.tmuxConfig, serverUrl: this.serverUrl, windowState: state }
)
}
this.sessions.delete(sessionId)
if (this.sessions.size === 0) {
this.pollingManager.stopPolling()
}
}
}

View File

@@ -0,0 +1,166 @@
import type { TmuxConfig } from "../../config/schema"
import type { TrackedSession, CapacityConfig } from "./types"
import { log } from "../../shared"
import { queryWindowState } from "./pane-state-querier"
import { decideSpawnActions, type SessionMapping } from "./decision-engine"
import { executeActions } from "./action-executor"
import { TmuxPollingManager } from "./polling-manager"
interface SessionCreatedEvent {
type: string
properties?: { info?: { id?: string; parentID?: string; title?: string } }
}
export interface TmuxUtilDeps {
isInsideTmux: () => boolean
getCurrentPaneId: () => string | undefined
}
export class SessionSpawner {
constructor(
private tmuxConfig: TmuxConfig,
private deps: TmuxUtilDeps,
private sessions: Map<string, TrackedSession>,
private pendingSessions: Set<string>,
private sourcePaneId: string | undefined,
private getCapacityConfig: () => CapacityConfig,
private getSessionMappings: () => SessionMapping[],
private waitForSessionReady: (sessionId: string) => Promise<boolean>,
private pollingManager: TmuxPollingManager,
private serverUrl: string
) {}
private isEnabled(): boolean {
return this.tmuxConfig.enabled && this.deps.isInsideTmux()
}
async onSessionCreated(event: SessionCreatedEvent): Promise<void> {
const enabled = this.isEnabled()
log("[tmux-session-manager] onSessionCreated called", {
enabled,
tmuxConfigEnabled: this.tmuxConfig.enabled,
isInsideTmux: this.deps.isInsideTmux(),
eventType: event.type,
infoId: event.properties?.info?.id,
infoParentID: event.properties?.info?.parentID,
})
if (!enabled) return
if (event.type !== "session.created") return
const info = event.properties?.info
if (!info?.id || !info?.parentID) return
const sessionId = info.id
const title = info.title ?? "Subagent"
if (this.sessions.has(sessionId) || this.pendingSessions.has(sessionId)) {
log("[tmux-session-manager] session already tracked or pending", { sessionId })
return
}
if (!this.sourcePaneId) {
log("[tmux-session-manager] no source pane id")
return
}
this.pendingSessions.add(sessionId)
try {
const state = await queryWindowState(this.sourcePaneId)
if (!state) {
log("[tmux-session-manager] failed to query window state")
return
}
log("[tmux-session-manager] window state queried", {
windowWidth: state.windowWidth,
mainPane: state.mainPane?.paneId,
agentPaneCount: state.agentPanes.length,
agentPanes: state.agentPanes.map((p) => p.paneId),
})
const decision = decideSpawnActions(
state,
sessionId,
title,
this.getCapacityConfig(),
this.getSessionMappings()
)
log("[tmux-session-manager] spawn decision", {
canSpawn: decision.canSpawn,
reason: decision.reason,
actionCount: decision.actions.length,
actions: decision.actions.map((a) => {
if (a.type === "close") return { type: "close", paneId: a.paneId }
if (a.type === "replace") return { type: "replace", paneId: a.paneId, newSessionId: a.newSessionId }
return { type: "spawn", sessionId: a.sessionId }
}),
})
if (!decision.canSpawn) {
log("[tmux-session-manager] cannot spawn", { reason: decision.reason })
return
}
const result = await executeActions(
decision.actions,
{ config: this.tmuxConfig, serverUrl: this.serverUrl, windowState: state }
)
for (const { action, result: actionResult } of result.results) {
if (action.type === "close" && actionResult.success) {
this.sessions.delete(action.sessionId)
log("[tmux-session-manager] removed closed session from cache", {
sessionId: action.sessionId,
})
}
if (action.type === "replace" && actionResult.success) {
this.sessions.delete(action.oldSessionId)
log("[tmux-session-manager] removed replaced session from cache", {
oldSessionId: action.oldSessionId,
newSessionId: action.newSessionId,
})
}
}
if (result.success && result.spawnedPaneId) {
const sessionReady = await this.waitForSessionReady(sessionId)
if (!sessionReady) {
log("[tmux-session-manager] session not ready after timeout, tracking anyway", {
sessionId,
paneId: result.spawnedPaneId,
})
}
const now = Date.now()
this.sessions.set(sessionId, {
sessionId,
paneId: result.spawnedPaneId,
description: title,
createdAt: new Date(now),
lastSeenAt: new Date(now),
})
log("[tmux-session-manager] pane spawned and tracked", {
sessionId,
paneId: result.spawnedPaneId,
sessionReady,
})
this.pollingManager.startPolling()
} else {
log("[tmux-session-manager] spawn failed", {
success: result.success,
results: result.results.map((r) => ({
type: r.action.type,
success: r.result.success,
error: r.result.error,
})),
})
}
} finally {
this.pendingSessions.delete(sessionId)
}
}
}

View File

@@ -2,7 +2,7 @@
## OVERVIEW
40+ lifecycle hooks intercepting/modifying agent behavior across 5 events.
163 lifecycle hooks intercepting/modifying agent behavior across 5 events.
**Event Types**:
- `UserPromptSubmit` (`chat.message`) - Can block

View File

@@ -1,5 +1,4 @@
export { createTodoContinuationEnforcer, type TodoContinuationEnforcer } from "./todo-continuation-enforcer";
export { createTaskContinuationEnforcer, type TaskContinuationEnforcer } from "./task-continuation-enforcer";
export { createContextWindowMonitorHook } from "./context-window-monitor";
export { createSessionNotification } from "./session-notification";
export { sendSessionNotification, playSessionNotificationSound, detectPlatform, getDefaultSoundPath } from "./session-notification-sender";

View File

@@ -0,0 +1,125 @@
import type { PluginInput } from "@opencode-ai/plugin";
import { loadInteractiveBashSessionState, saveInteractiveBashSessionState, clearInteractiveBashSessionState } from "./storage";
import { buildSessionReminderMessage } from "./constants";
import type { InteractiveBashSessionState } from "./types";
import { tokenizeCommand, findSubcommand, extractSessionNameFromTokens } from "./parser";
import { getOrCreateState, isOmoSession, killAllTrackedSessions } from "./state-manager";
import { subagentSessions } from "../../features/claude-code-session-state";
interface ToolExecuteInput {
tool: string;
sessionID: string;
callID: string;
args?: Record<string, unknown>;
}
interface ToolExecuteOutput {
title: string;
output: string;
metadata: unknown;
}
interface EventInput {
event: {
type: string;
properties?: unknown;
};
}
export function createInteractiveBashSessionHook(ctx: PluginInput) {
const sessionStates = new Map<string, InteractiveBashSessionState>();
function getOrCreateStateLocal(sessionID: string): InteractiveBashSessionState {
return getOrCreateState(sessionID, sessionStates);
}
async function killAllTrackedSessionsLocal(
state: InteractiveBashSessionState,
): Promise<void> {
await killAllTrackedSessions(state);
for (const sessionId of subagentSessions) {
ctx.client.session.abort({ path: { id: sessionId } }).catch(() => {})
}
}
const toolExecuteAfter = async (
input: ToolExecuteInput,
output: ToolExecuteOutput,
) => {
const { tool, sessionID, args } = input;
const toolLower = tool.toLowerCase();
if (toolLower !== "interactive_bash") {
return;
}
if (typeof args?.tmux_command !== "string") {
return;
}
const tmuxCommand = args.tmux_command;
const tokens = tokenizeCommand(tmuxCommand);
const subCommand = findSubcommand(tokens);
const state = getOrCreateStateLocal(sessionID);
let stateChanged = false;
const toolOutput = output?.output ?? ""
if (toolOutput.startsWith("Error:")) {
return
}
const isNewSession = subCommand === "new-session";
const isKillSession = subCommand === "kill-session";
const isKillServer = subCommand === "kill-server";
const sessionName = extractSessionNameFromTokens(tokens, subCommand);
if (isNewSession && isOmoSession(sessionName)) {
state.tmuxSessions.add(sessionName!);
stateChanged = true;
} else if (isKillSession && isOmoSession(sessionName)) {
state.tmuxSessions.delete(sessionName!);
stateChanged = true;
} else if (isKillServer) {
state.tmuxSessions.clear();
stateChanged = true;
}
if (stateChanged) {
state.updatedAt = Date.now();
saveInteractiveBashSessionState(state);
}
const isSessionOperation = isNewSession || isKillSession || isKillServer;
if (isSessionOperation) {
const reminder = buildSessionReminderMessage(
Array.from(state.tmuxSessions),
);
if (reminder) {
output.output += reminder;
}
}
};
const eventHandler = async ({ event }: EventInput) => {
const props = event.properties as Record<string, unknown> | undefined;
if (event.type === "session.deleted") {
const sessionInfo = props?.info as { id?: string } | undefined;
const sessionID = sessionInfo?.id;
if (sessionID) {
const state = getOrCreateStateLocal(sessionID);
await killAllTrackedSessionsLocal(state);
sessionStates.delete(sessionID);
clearInteractiveBashSessionState(sessionID);
}
}
};
return {
"tool.execute.after": toolExecuteAfter,
event: eventHandler,
};
}

View File

@@ -0,0 +1,118 @@
/**
* Quote-aware command tokenizer with escape handling
* Handles single/double quotes and backslash escapes
*/
export function tokenizeCommand(cmd: string): string[] {
const tokens: string[] = []
let current = ""
let inQuote = false
let quoteChar = ""
let escaped = false
for (let i = 0; i < cmd.length; i++) {
const char = cmd[i]
if (escaped) {
current += char
escaped = false
continue
}
if (char === "\\") {
escaped = true
continue
}
if ((char === "'" || char === '"') && !inQuote) {
inQuote = true
quoteChar = char
} else if (char === quoteChar && inQuote) {
inQuote = false
quoteChar = ""
} else if (char === " " && !inQuote) {
if (current) {
tokens.push(current)
current = ""
}
} else {
current += char
}
}
if (current) tokens.push(current)
return tokens
}
/**
* Normalize session name by stripping :window and .pane suffixes
* e.g., "omo-x:1" -> "omo-x", "omo-x:1.2" -> "omo-x"
*/
export function normalizeSessionName(name: string): string {
return name.split(":")[0].split(".")[0]
}
export function findFlagValue(tokens: string[], flag: string): string | null {
for (let i = 0; i < tokens.length - 1; i++) {
if (tokens[i] === flag) return tokens[i + 1]
}
return null
}
/**
* Extract session name from tokens, considering the subCommand
* For new-session: prioritize -s over -t
* For other commands: use -t
*/
export function extractSessionNameFromTokens(tokens: string[], subCommand: string): string | null {
if (subCommand === "new-session") {
const sFlag = findFlagValue(tokens, "-s")
if (sFlag) return normalizeSessionName(sFlag)
const tFlag = findFlagValue(tokens, "-t")
if (tFlag) return normalizeSessionName(tFlag)
} else {
const tFlag = findFlagValue(tokens, "-t")
if (tFlag) return normalizeSessionName(tFlag)
}
return null
}
/**
* Find the tmux subcommand from tokens, skipping global options.
* tmux allows global options before the subcommand:
* e.g., `tmux -L socket-name new-session -s omo-x`
* Global options with args: -L, -S, -f, -c, -T
* Standalone flags: -C, -v, -V, etc.
* Special: -- (end of options marker)
*/
export function findSubcommand(tokens: string[]): string {
// Options that require an argument: -L, -S, -f, -c, -T
const globalOptionsWithArgs = new Set(["-L", "-S", "-f", "-c", "-T"])
let i = 0
while (i < tokens.length) {
const token = tokens[i]
// Handle end of options marker
if (token === "--") {
// Next token is the subcommand
return tokens[i + 1] ?? ""
}
if (globalOptionsWithArgs.has(token)) {
// Skip the option and its argument
i += 2
continue
}
if (token.startsWith("-")) {
// Skip standalone flags like -C, -v, -V
i++
continue
}
// Found the subcommand
return token
}
return ""
}

View File

@@ -0,0 +1,40 @@
import type { InteractiveBashSessionState } from "./types";
import { loadInteractiveBashSessionState, saveInteractiveBashSessionState } from "./storage";
import { OMO_SESSION_PREFIX } from "./constants";
import { subagentSessions } from "../../features/claude-code-session-state";
export function getOrCreateState(sessionID: string, sessionStates: Map<string, InteractiveBashSessionState>): InteractiveBashSessionState {
if (!sessionStates.has(sessionID)) {
const persisted = loadInteractiveBashSessionState(sessionID);
const state: InteractiveBashSessionState = persisted ?? {
sessionID,
tmuxSessions: new Set<string>(),
updatedAt: Date.now(),
};
sessionStates.set(sessionID, state);
}
return sessionStates.get(sessionID)!;
}
export function isOmoSession(sessionName: string | null): boolean {
return sessionName !== null && sessionName.startsWith(OMO_SESSION_PREFIX);
}
export async function killAllTrackedSessions(
state: InteractiveBashSessionState,
): Promise<void> {
for (const sessionName of state.tmuxSessions) {
try {
const proc = Bun.spawn(["tmux", "kill-session", "-t", sessionName], {
stdout: "ignore",
stderr: "ignore",
});
await proc.exited;
} catch {}
}
for (const sessionId of subagentSessions) {
// Note: ctx is not available here, so we can't call ctx.client.session.abort
// This will need to be handled in the hook where ctx is available
}
}

View File

@@ -1,763 +0,0 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
import { mkdtempSync, rmSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
import type { BackgroundManager } from "../features/background-agent"
import { setMainSession, subagentSessions, _resetForTesting } from "../features/claude-code-session-state"
import type { OhMyOpenCodeConfig } from "../config/schema"
import { TaskObjectSchema } from "../tools/task/types"
import type { TaskObject } from "../tools/task/types"
import { createTaskContinuationEnforcer } from "./task-continuation-enforcer"
type TimerCallback = (...args: any[]) => void
interface FakeTimers {
advanceBy: (ms: number, advanceClock?: boolean) => Promise<void>
restore: () => void
}
function createFakeTimers(): FakeTimers {
const originalNow = Date.now()
let clockNow = originalNow
let timerNow = 0
let nextId = 1
const timers = new Map<number, { id: number; time: number; interval: number | null; callback: TimerCallback; args: any[] }>()
const cleared = new Set<number>()
const original = {
setTimeout: globalThis.setTimeout,
clearTimeout: globalThis.clearTimeout,
setInterval: globalThis.setInterval,
clearInterval: globalThis.clearInterval,
dateNow: Date.now,
}
const normalizeDelay = (delay?: number) => {
if (typeof delay !== "number" || !Number.isFinite(delay)) return 0
return delay < 0 ? 0 : delay
}
const schedule = (callback: TimerCallback, delay: number | undefined, interval: number | null, args: any[]) => {
const id = nextId++
timers.set(id, {
id,
time: timerNow + normalizeDelay(delay),
interval,
callback,
args,
})
return id
}
const clear = (id: number | undefined) => {
if (typeof id !== "number") return
cleared.add(id)
timers.delete(id)
}
globalThis.setTimeout = ((callback: TimerCallback, delay?: number, ...args: any[]) => {
return schedule(callback, delay, null, args) as unknown as ReturnType<typeof setTimeout>
}) as typeof setTimeout
globalThis.setInterval = ((callback: TimerCallback, delay?: number, ...args: any[]) => {
const interval = normalizeDelay(delay)
return schedule(callback, delay, interval, args) as unknown as ReturnType<typeof setInterval>
}) as typeof setInterval
globalThis.clearTimeout = ((id?: number) => {
clear(id)
}) as typeof clearTimeout
globalThis.clearInterval = ((id?: number) => {
clear(id)
}) as typeof clearInterval
Date.now = () => clockNow
const advanceBy = async (ms: number, advanceClock: boolean = false) => {
const clamped = Math.max(0, ms)
const target = timerNow + clamped
if (advanceClock) {
clockNow += clamped
}
while (true) {
let next: { id: number; time: number; interval: number | null; callback: TimerCallback; args: any[] } | undefined
for (const timer of timers.values()) {
if (timer.time <= target && (!next || timer.time < next.time)) {
next = timer
}
}
if (!next) break
timerNow = next.time
timers.delete(next.id)
next.callback(...next.args)
if (next.interval !== null && !cleared.has(next.id)) {
timers.set(next.id, {
id: next.id,
time: timerNow + next.interval,
interval: next.interval,
callback: next.callback,
args: next.args,
})
} else {
cleared.delete(next.id)
}
await Promise.resolve()
}
timerNow = target
await Promise.resolve()
}
const restore = () => {
globalThis.setTimeout = original.setTimeout
globalThis.clearTimeout = original.clearTimeout
globalThis.setInterval = original.setInterval
globalThis.clearInterval = original.clearInterval
Date.now = original.dateNow
}
return { advanceBy, restore }
}
const wait = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms))
describe("task-continuation-enforcer", () => {
let promptCalls: Array<{ sessionID: string; agent?: string; model?: { providerID?: string; modelID?: string }; text: string }>
let toastCalls: Array<{ title: string; message: string }>
let fakeTimers: FakeTimers
let taskDir: string
interface MockMessage {
info: {
id: string
role: "user" | "assistant"
error?: { name: string; data?: { message: string } }
}
}
let mockMessages: MockMessage[] = []
function createMockPluginInput() {
return {
client: {
session: {
messages: async () => ({ data: mockMessages }),
prompt: async (opts: any) => {
promptCalls.push({
sessionID: opts.path.id,
agent: opts.body.agent,
model: opts.body.model,
text: opts.body.parts[0].text,
})
return {}
},
},
tui: {
showToast: async (opts: any) => {
toastCalls.push({
title: opts.body.title,
message: opts.body.message,
})
return {}
},
},
},
directory: "/tmp/test",
} as any
}
function createTempTaskDir(): string {
return mkdtempSync(join(tmpdir(), "omo-task-continuation-"))
}
function writeTaskFile(dir: string, task: TaskObject): void {
const parsed = TaskObjectSchema.safeParse(task)
expect(parsed.success).toBe(true)
if (!parsed.success) return
writeFileSync(join(dir, `${parsed.data.id}.json`), JSON.stringify(parsed.data), "utf-8")
}
function writeCorruptedTaskFile(dir: string, taskId: string): void {
writeFileSync(join(dir, `${taskId}.json`), "{ this is not valid json", "utf-8")
}
function createConfig(dir: string): Partial<OhMyOpenCodeConfig> {
return {
sisyphus: {
tasks: {
claude_code_compat: true,
storage_path: dir,
},
},
}
}
function createMockBackgroundManager(runningTasks: boolean = false): BackgroundManager {
return {
getTasksByParentSession: () => (runningTasks ? [{ status: "running" }] : []),
} as any
}
beforeEach(() => {
fakeTimers = createFakeTimers()
_resetForTesting()
promptCalls = []
toastCalls = []
mockMessages = []
taskDir = createTempTaskDir()
})
afterEach(() => {
fakeTimers.restore()
_resetForTesting()
rmSync(taskDir, { recursive: true, force: true })
})
test("should inject continuation when idle with incomplete tasks on disk", async () => {
fakeTimers.restore()
// given - main session with incomplete tasks
const sessionID = "main-123"
setMainSession(sessionID)
writeTaskFile(taskDir, {
id: "T-1",
subject: "Task 1",
description: "",
status: "pending",
blocks: [],
blockedBy: [],
threadID: "test",
})
writeTaskFile(taskDir, {
id: "T-2",
subject: "Task 2",
description: "",
status: "completed",
blocks: [],
blockedBy: [],
threadID: "test",
})
const hook = createTaskContinuationEnforcer(createMockPluginInput(), createConfig(taskDir), {
backgroundManager: createMockBackgroundManager(false),
})
// when - session goes idle
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
// then - countdown toast shown
await wait(50)
expect(toastCalls.length).toBeGreaterThanOrEqual(1)
expect(toastCalls[0].title).toBe("Task Continuation")
// then - after countdown, continuation injected
await wait(2500)
expect(promptCalls.length).toBe(1)
expect(promptCalls[0].text).toContain("TASK CONTINUATION")
}, { timeout: 15000 })
test("should NOT inject when all tasks are completed", async () => {
// given - session with all tasks completed
const sessionID = "main-456"
setMainSession(sessionID)
writeTaskFile(taskDir, {
id: "T-1",
subject: "Task 1",
description: "",
status: "completed",
blocks: [],
blockedBy: [],
threadID: "test",
})
const hook = createTaskContinuationEnforcer(createMockPluginInput(), createConfig(taskDir), {})
// when - session goes idle
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
await fakeTimers.advanceBy(3000)
// then - no continuation injected
expect(promptCalls).toHaveLength(0)
})
test("should NOT inject when all tasks are deleted", async () => {
// given - session with all tasks deleted
const sessionID = "main-deleted"
setMainSession(sessionID)
writeTaskFile(taskDir, {
id: "T-1",
subject: "Task 1",
description: "",
status: "deleted",
blocks: [],
blockedBy: [],
threadID: "test",
})
const hook = createTaskContinuationEnforcer(createMockPluginInput(), createConfig(taskDir), {})
// when
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
await fakeTimers.advanceBy(3000)
// then
expect(promptCalls).toHaveLength(0)
})
test("should NOT inject when no task files exist", async () => {
// given - empty task directory
const sessionID = "main-none"
setMainSession(sessionID)
const hook = createTaskContinuationEnforcer(createMockPluginInput(), createConfig(taskDir), {})
// when
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
await fakeTimers.advanceBy(3000)
// then
expect(promptCalls).toHaveLength(0)
})
test("should NOT inject when background tasks are running", async () => {
// given - session with incomplete tasks and running background tasks
const sessionID = "main-bg-running"
setMainSession(sessionID)
writeTaskFile(taskDir, {
id: "T-1",
subject: "Task 1",
description: "",
status: "pending",
blocks: [],
blockedBy: [],
threadID: "test",
})
const hook = createTaskContinuationEnforcer(createMockPluginInput(), createConfig(taskDir), {
backgroundManager: createMockBackgroundManager(true),
})
// when
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
await fakeTimers.advanceBy(3000)
// then
expect(promptCalls).toHaveLength(0)
})
test("should NOT inject for non-main session", async () => {
// given - main session set, different session goes idle
setMainSession("main-session")
const otherSession = "other-session"
writeTaskFile(taskDir, {
id: "T-1",
subject: "Task 1",
description: "",
status: "pending",
blocks: [],
blockedBy: [],
threadID: "test",
})
const hook = createTaskContinuationEnforcer(createMockPluginInput(), createConfig(taskDir), {})
// when
await hook.handler({ event: { type: "session.idle", properties: { sessionID: otherSession } } })
await fakeTimers.advanceBy(3000)
// then
expect(promptCalls).toHaveLength(0)
})
test("should inject for background task session (subagent)", async () => {
fakeTimers.restore()
// given - main session set, background task session registered
setMainSession("main-session")
const bgTaskSession = "bg-task-session"
subagentSessions.add(bgTaskSession)
writeTaskFile(taskDir, {
id: "T-1",
subject: "Task 1",
description: "",
status: "pending",
blocks: [],
blockedBy: [],
threadID: "test",
})
const hook = createTaskContinuationEnforcer(createMockPluginInput(), createConfig(taskDir), {})
// when
await hook.handler({ event: { type: "session.idle", properties: { sessionID: bgTaskSession } } })
// then
await wait(2500)
expect(promptCalls.length).toBe(1)
expect(promptCalls[0].sessionID).toBe(bgTaskSession)
}, { timeout: 15000 })
test("should cancel countdown on user message after grace period", async () => {
// given
const sessionID = "main-cancel"
setMainSession(sessionID)
writeTaskFile(taskDir, {
id: "T-1",
subject: "Task 1",
description: "",
status: "pending",
blocks: [],
blockedBy: [],
threadID: "test",
})
const hook = createTaskContinuationEnforcer(createMockPluginInput(), createConfig(taskDir), {})
// when - session goes idle
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
// when - wait past grace period (500ms), then user sends message
await fakeTimers.advanceBy(600, true)
await hook.handler({
event: {
type: "message.updated",
properties: { info: { sessionID, role: "user" } },
},
})
// then
await fakeTimers.advanceBy(2500)
expect(promptCalls).toHaveLength(0)
})
test("should ignore user message within grace period", async () => {
fakeTimers.restore()
// given
const sessionID = "main-grace"
setMainSession(sessionID)
writeTaskFile(taskDir, {
id: "T-1",
subject: "Task 1",
description: "",
status: "pending",
blocks: [],
blockedBy: [],
threadID: "test",
})
const hook = createTaskContinuationEnforcer(createMockPluginInput(), createConfig(taskDir), {})
// when
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
await hook.handler({
event: {
type: "message.updated",
properties: { info: { sessionID, role: "user" } },
},
})
// then - countdown should continue
await wait(2500)
expect(promptCalls).toHaveLength(1)
}, { timeout: 15000 })
test("should cancel countdown on assistant activity", async () => {
// given
const sessionID = "main-assistant"
setMainSession(sessionID)
writeTaskFile(taskDir, {
id: "T-1",
subject: "Task 1",
description: "",
status: "pending",
blocks: [],
blockedBy: [],
threadID: "test",
})
const hook = createTaskContinuationEnforcer(createMockPluginInput(), createConfig(taskDir), {})
// when
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
await fakeTimers.advanceBy(500)
await hook.handler({
event: {
type: "message.part.updated",
properties: { info: { sessionID, role: "assistant" } },
},
})
// then
await fakeTimers.advanceBy(3000)
expect(promptCalls).toHaveLength(0)
})
test("should cancel countdown on tool execution", async () => {
// given
const sessionID = "main-tool"
setMainSession(sessionID)
writeTaskFile(taskDir, {
id: "T-1",
subject: "Task 1",
description: "",
status: "pending",
blocks: [],
blockedBy: [],
threadID: "test",
})
const hook = createTaskContinuationEnforcer(createMockPluginInput(), createConfig(taskDir), {})
// when
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
await fakeTimers.advanceBy(500)
await hook.handler({ event: { type: "tool.execute.before", properties: { sessionID } } })
// then
await fakeTimers.advanceBy(3000)
expect(promptCalls).toHaveLength(0)
})
test("should skip injection during recovery mode", async () => {
// given
const sessionID = "main-recovery"
setMainSession(sessionID)
writeTaskFile(taskDir, {
id: "T-1",
subject: "Task 1",
description: "",
status: "pending",
blocks: [],
blockedBy: [],
threadID: "test",
})
const hook = createTaskContinuationEnforcer(createMockPluginInput(), createConfig(taskDir), {})
// when
hook.markRecovering(sessionID)
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
await fakeTimers.advanceBy(3000)
// then
expect(promptCalls).toHaveLength(0)
})
test("should inject after recovery complete", async () => {
fakeTimers.restore()
// given
const sessionID = "main-recovery-done"
setMainSession(sessionID)
writeTaskFile(taskDir, {
id: "T-1",
subject: "Task 1",
description: "",
status: "pending",
blocks: [],
blockedBy: [],
threadID: "test",
})
const hook = createTaskContinuationEnforcer(createMockPluginInput(), createConfig(taskDir), {})
// when
hook.markRecovering(sessionID)
hook.markRecoveryComplete(sessionID)
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
// then
await wait(3000)
expect(promptCalls.length).toBe(1)
}, { timeout: 15000 })
test("should cleanup on session deleted", async () => {
// given
const sessionID = "main-delete"
setMainSession(sessionID)
writeTaskFile(taskDir, {
id: "T-1",
subject: "Task 1",
description: "",
status: "pending",
blocks: [],
blockedBy: [],
threadID: "test",
})
const hook = createTaskContinuationEnforcer(createMockPluginInput(), createConfig(taskDir), {})
// when
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
await fakeTimers.advanceBy(500)
await hook.handler({ event: { type: "session.deleted", properties: { info: { id: sessionID } } } })
await fakeTimers.advanceBy(3000)
// then
expect(promptCalls).toHaveLength(0)
})
test("should skip when last assistant message was aborted (API fallback)", async () => {
// given
const sessionID = "main-api-abort"
setMainSession(sessionID)
writeTaskFile(taskDir, {
id: "T-1",
subject: "Task 1",
description: "",
status: "pending",
blocks: [],
blockedBy: [],
threadID: "test",
})
mockMessages = [
{ info: { id: "msg-1", role: "user" } },
{ info: { id: "msg-2", role: "assistant", error: { name: "MessageAbortedError", data: { message: "aborted" } } } },
]
const hook = createTaskContinuationEnforcer(createMockPluginInput(), createConfig(taskDir), {})
// when
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
await fakeTimers.advanceBy(3000)
// then
expect(promptCalls).toHaveLength(0)
})
test("should skip when abort detected via session.error event", async () => {
// given
const sessionID = "main-event-abort"
setMainSession(sessionID)
writeTaskFile(taskDir, {
id: "T-1",
subject: "Task 1",
description: "",
status: "pending",
blocks: [],
blockedBy: [],
threadID: "test",
})
mockMessages = [
{ info: { id: "msg-1", role: "user" } },
{ info: { id: "msg-2", role: "assistant" } },
]
const hook = createTaskContinuationEnforcer(createMockPluginInput(), createConfig(taskDir), {})
// when - abort error event fires
await hook.handler({
event: {
type: "session.error",
properties: { sessionID, error: { name: "MessageAbortedError" } },
},
})
// when - session goes idle immediately after
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
await fakeTimers.advanceBy(3000)
// then
expect(promptCalls).toHaveLength(0)
})
test("should handle corrupted task files gracefully (readJsonSafe returns null)", async () => {
fakeTimers.restore()
// given
const sessionID = "main-corrupt"
setMainSession(sessionID)
writeCorruptedTaskFile(taskDir, "T-corrupt")
writeTaskFile(taskDir, {
id: "T-ok",
subject: "Task OK",
description: "",
status: "pending",
blocks: [],
blockedBy: [],
threadID: "test",
})
const hook = createTaskContinuationEnforcer(createMockPluginInput(), createConfig(taskDir), {})
// when
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
await wait(2500)
// then
expect(promptCalls).toHaveLength(1)
}, { timeout: 15000 })
test("should NOT inject when isContinuationStopped returns true", async () => {
// given
const sessionID = "main-stopped"
setMainSession(sessionID)
writeTaskFile(taskDir, {
id: "T-1",
subject: "Task 1",
description: "",
status: "pending",
blocks: [],
blockedBy: [],
threadID: "test",
})
const hook = createTaskContinuationEnforcer(createMockPluginInput(), createConfig(taskDir), {
isContinuationStopped: (id) => id === sessionID,
})
// when
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
await fakeTimers.advanceBy(3000)
// then
expect(promptCalls).toHaveLength(0)
})
test("should cancel all countdowns via cancelAllCountdowns", async () => {
// given
const sessionID = "main-cancel-all"
setMainSession(sessionID)
writeTaskFile(taskDir, {
id: "T-1",
subject: "Task 1",
description: "",
status: "pending",
blocks: [],
blockedBy: [],
threadID: "test",
})
const hook = createTaskContinuationEnforcer(createMockPluginInput(), createConfig(taskDir), {})
// when
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
await fakeTimers.advanceBy(500)
hook.cancelAllCountdowns()
await fakeTimers.advanceBy(3000)
// then
expect(promptCalls).toHaveLength(0)
})
})

View File

@@ -1,530 +0,0 @@
import type { PluginInput } from "@opencode-ai/plugin"
import { existsSync, readdirSync } from "node:fs"
import { join } from "node:path"
import type { BackgroundManager } from "../features/background-agent"
import { getMainSessionID, subagentSessions } from "../features/claude-code-session-state"
import {
findNearestMessageWithFields,
MESSAGE_STORAGE,
type ToolPermission,
} from "../features/hook-message-injector"
import { listTaskFiles, readJsonSafe, getTaskDir } from "../features/claude-tasks/storage"
import type { OhMyOpenCodeConfig } from "../config/schema"
import { TaskObjectSchema } from "../tools/task/types"
import type { TaskObject } from "../tools/task/types"
import { log } from "../shared/logger"
import { createSystemDirective, SystemDirectiveTypes } from "../shared/system-directive"
const HOOK_NAME = "task-continuation-enforcer"
const DEFAULT_SKIP_AGENTS = ["prometheus", "compaction"]
export interface TaskContinuationEnforcerOptions {
backgroundManager?: BackgroundManager
skipAgents?: string[]
isContinuationStopped?: (sessionID: string) => boolean
}
export interface TaskContinuationEnforcer {
handler: (input: { event: { type: string; properties?: unknown } }) => Promise<void>
markRecovering: (sessionID: string) => void
markRecoveryComplete: (sessionID: string) => void
cancelAllCountdowns: () => void
}
interface SessionState {
countdownTimer?: ReturnType<typeof setTimeout>
countdownInterval?: ReturnType<typeof setInterval>
isRecovering?: boolean
countdownStartedAt?: number
abortDetectedAt?: number
}
const CONTINUATION_PROMPT = `${createSystemDirective(SystemDirectiveTypes.TASK_CONTINUATION)}
Incomplete tasks remain in your task list. Continue working on the next pending task.
- Proceed without asking for permission
- Mark each task complete when finished
- Do not stop until all tasks are done`
const COUNTDOWN_SECONDS = 2
const TOAST_DURATION_MS = 900
const COUNTDOWN_GRACE_PERIOD_MS = 500
function getMessageDir(sessionID: string): string | null {
if (!existsSync(MESSAGE_STORAGE)) return null
const directPath = join(MESSAGE_STORAGE, sessionID)
if (existsSync(directPath)) return directPath
for (const dir of readdirSync(MESSAGE_STORAGE)) {
const sessionPath = join(MESSAGE_STORAGE, dir, sessionID)
if (existsSync(sessionPath)) return sessionPath
}
return null
}
function getIncompleteCount(tasks: TaskObject[]): number {
return tasks.filter(t => t.status !== "completed" && t.status !== "deleted").length
}
interface MessageInfo {
id?: string
role?: string
error?: { name?: string; data?: unknown }
}
function isLastAssistantMessageAborted(messages: Array<{ info?: MessageInfo }>): boolean {
if (!messages || messages.length === 0) return false
const assistantMessages = messages.filter(m => m.info?.role === "assistant")
if (assistantMessages.length === 0) return false
const lastAssistant = assistantMessages[assistantMessages.length - 1]
const errorName = lastAssistant.info?.error?.name
if (!errorName) return false
return errorName === "MessageAbortedError" || errorName === "AbortError"
}
function loadTasksFromDisk(config: Partial<OhMyOpenCodeConfig>): TaskObject[] {
const taskIds = listTaskFiles(config)
const taskDirectory = getTaskDir(config)
const tasks: TaskObject[] = []
for (const id of taskIds) {
const task = readJsonSafe<TaskObject>(join(taskDirectory, `${id}.json`), TaskObjectSchema)
if (task) tasks.push(task)
}
return tasks
}
export function createTaskContinuationEnforcer(
ctx: PluginInput,
config: Partial<OhMyOpenCodeConfig>,
options: TaskContinuationEnforcerOptions = {}
): TaskContinuationEnforcer {
const { backgroundManager, skipAgents = DEFAULT_SKIP_AGENTS, isContinuationStopped } = options
const sessions = new Map<string, SessionState>()
function getState(sessionID: string): SessionState {
let state = sessions.get(sessionID)
if (!state) {
state = {}
sessions.set(sessionID, state)
}
return state
}
function cancelCountdown(sessionID: string): void {
const state = sessions.get(sessionID)
if (!state) return
if (state.countdownTimer) {
clearTimeout(state.countdownTimer)
state.countdownTimer = undefined
}
if (state.countdownInterval) {
clearInterval(state.countdownInterval)
state.countdownInterval = undefined
}
state.countdownStartedAt = undefined
}
function cleanup(sessionID: string): void {
cancelCountdown(sessionID)
sessions.delete(sessionID)
}
const markRecovering = (sessionID: string): void => {
const state = getState(sessionID)
state.isRecovering = true
cancelCountdown(sessionID)
log(`[${HOOK_NAME}] Session marked as recovering`, { sessionID })
}
const markRecoveryComplete = (sessionID: string): void => {
const state = sessions.get(sessionID)
if (state) {
state.isRecovering = false
log(`[${HOOK_NAME}] Session recovery complete`, { sessionID })
}
}
async function showCountdownToast(seconds: number, incompleteCount: number): Promise<void> {
await ctx.client.tui
.showToast({
body: {
title: "Task Continuation",
message: `Resuming in ${seconds}s... (${incompleteCount} tasks remaining)`,
variant: "warning" as const,
duration: TOAST_DURATION_MS,
},
})
.catch(() => {})
}
interface ResolvedMessageInfo {
agent?: string
model?: { providerID: string; modelID: string }
tools?: Record<string, ToolPermission>
}
async function injectContinuation(
sessionID: string,
incompleteCount: number,
total: number,
resolvedInfo?: ResolvedMessageInfo
): Promise<void> {
const state = sessions.get(sessionID)
if (state?.isRecovering) {
log(`[${HOOK_NAME}] Skipped injection: in recovery`, { sessionID })
return
}
const hasRunningBgTasks = backgroundManager
? backgroundManager.getTasksByParentSession(sessionID).some(t => t.status === "running")
: false
if (hasRunningBgTasks) {
log(`[${HOOK_NAME}] Skipped injection: background tasks running`, { sessionID })
return
}
const tasks = loadTasksFromDisk(config)
const freshIncompleteCount = getIncompleteCount(tasks)
if (freshIncompleteCount === 0) {
log(`[${HOOK_NAME}] Skipped injection: no incomplete tasks`, { sessionID })
return
}
let agentName = resolvedInfo?.agent
let model = resolvedInfo?.model
let tools = resolvedInfo?.tools
if (!agentName || !model) {
const messageDir = getMessageDir(sessionID)
const prevMessage = messageDir ? findNearestMessageWithFields(messageDir) : null
agentName = agentName ?? prevMessage?.agent
model =
model ??
(prevMessage?.model?.providerID && prevMessage?.model?.modelID
? {
providerID: prevMessage.model.providerID,
modelID: prevMessage.model.modelID,
...(prevMessage.model.variant ? { variant: prevMessage.model.variant } : {}),
}
: undefined)
tools = tools ?? prevMessage?.tools
}
if (agentName && skipAgents.includes(agentName)) {
log(`[${HOOK_NAME}] Skipped: agent in skipAgents list`, { sessionID, agent: agentName })
return
}
const editPermission = tools?.edit
const writePermission = tools?.write
const hasWritePermission =
!tools ||
(editPermission !== false && editPermission !== "deny" && writePermission !== false && writePermission !== "deny")
if (!hasWritePermission) {
log(`[${HOOK_NAME}] Skipped: agent lacks write permission`, { sessionID, agent: agentName })
return
}
const incompleteTasks = tasks.filter(t => t.status !== "completed" && t.status !== "deleted")
const taskList = incompleteTasks.map(t => `- [${t.status}] ${t.subject}`).join("\n")
const prompt = `${CONTINUATION_PROMPT}
[Status: ${tasks.length - freshIncompleteCount}/${tasks.length} completed, ${freshIncompleteCount} remaining]
Remaining tasks:
${taskList}`
try {
log(`[${HOOK_NAME}] Injecting continuation`, {
sessionID,
agent: agentName,
model,
incompleteCount: freshIncompleteCount,
})
await ctx.client.session.prompt({
path: { id: sessionID },
body: {
agent: agentName,
...(model !== undefined ? { model } : {}),
parts: [{ type: "text", text: prompt }],
},
query: { directory: ctx.directory },
})
log(`[${HOOK_NAME}] Injection successful`, { sessionID })
} catch (err) {
log(`[${HOOK_NAME}] Injection failed`, { sessionID, error: String(err) })
}
}
function startCountdown(
sessionID: string,
incompleteCount: number,
total: number,
resolvedInfo?: ResolvedMessageInfo
): void {
const state = getState(sessionID)
cancelCountdown(sessionID)
let secondsRemaining = COUNTDOWN_SECONDS
showCountdownToast(secondsRemaining, incompleteCount)
state.countdownStartedAt = Date.now()
state.countdownInterval = setInterval(() => {
secondsRemaining--
if (secondsRemaining > 0) {
showCountdownToast(secondsRemaining, incompleteCount)
}
}, 1000)
state.countdownTimer = setTimeout(() => {
cancelCountdown(sessionID)
injectContinuation(sessionID, incompleteCount, total, resolvedInfo)
}, COUNTDOWN_SECONDS * 1000)
log(`[${HOOK_NAME}] Countdown started`, { sessionID, seconds: COUNTDOWN_SECONDS, incompleteCount })
}
const handler = async ({ event }: { event: { type: string; properties?: unknown } }): Promise<void> => {
const props = event.properties as Record<string, unknown> | undefined
if (event.type === "session.error") {
const sessionID = props?.sessionID as string | undefined
if (!sessionID) return
const error = props?.error as { name?: string } | undefined
if (error?.name === "MessageAbortedError" || error?.name === "AbortError") {
const state = getState(sessionID)
state.abortDetectedAt = Date.now()
log(`[${HOOK_NAME}] Abort detected via session.error`, { sessionID, errorName: error.name })
}
cancelCountdown(sessionID)
log(`[${HOOK_NAME}] session.error`, { sessionID })
return
}
if (event.type === "session.idle") {
const sessionID = props?.sessionID as string | undefined
if (!sessionID) return
log(`[${HOOK_NAME}] session.idle`, { sessionID })
const mainSessionID = getMainSessionID()
const isMainSession = sessionID === mainSessionID
const isBackgroundTaskSession = subagentSessions.has(sessionID)
if (mainSessionID && !isMainSession && !isBackgroundTaskSession) {
log(`[${HOOK_NAME}] Skipped: not main or background task session`, { sessionID })
return
}
const state = getState(sessionID)
if (state.isRecovering) {
log(`[${HOOK_NAME}] Skipped: in recovery`, { sessionID })
return
}
// Check 1: Event-based abort detection (primary, most reliable)
if (state.abortDetectedAt) {
const timeSinceAbort = Date.now() - state.abortDetectedAt
const ABORT_WINDOW_MS = 3000
if (timeSinceAbort < ABORT_WINDOW_MS) {
log(`[${HOOK_NAME}] Skipped: abort detected via event ${timeSinceAbort}ms ago`, { sessionID })
state.abortDetectedAt = undefined
return
}
state.abortDetectedAt = undefined
}
const hasRunningBgTasks = backgroundManager
? backgroundManager.getTasksByParentSession(sessionID).some(t => t.status === "running")
: false
if (hasRunningBgTasks) {
log(`[${HOOK_NAME}] Skipped: background tasks running`, { sessionID })
return
}
// Check 2: API-based abort detection (fallback, for cases where event was missed)
try {
const messagesResp = await ctx.client.session.messages({
path: { id: sessionID },
query: { directory: ctx.directory },
})
const messages = (messagesResp as { data?: Array<{ info?: MessageInfo }> }).data ?? []
if (isLastAssistantMessageAborted(messages)) {
log(`[${HOOK_NAME}] Skipped: last assistant message was aborted (API fallback)`, { sessionID })
return
}
} catch (err) {
log(`[${HOOK_NAME}] Messages fetch failed, continuing`, { sessionID, error: String(err) })
}
const tasks = loadTasksFromDisk(config)
if (!tasks || tasks.length === 0) {
log(`[${HOOK_NAME}] No tasks`, { sessionID })
return
}
const incompleteCount = getIncompleteCount(tasks)
if (incompleteCount === 0) {
log(`[${HOOK_NAME}] All tasks complete`, { sessionID, total: tasks.length })
return
}
let resolvedInfo: ResolvedMessageInfo | undefined
let hasCompactionMessage = false
try {
const messagesResp = await ctx.client.session.messages({
path: { id: sessionID },
})
const messages = (messagesResp.data ?? []) as Array<{
info?: {
agent?: string
model?: { providerID: string; modelID: string }
modelID?: string
providerID?: string
tools?: Record<string, ToolPermission>
}
}>
for (let i = messages.length - 1; i >= 0; i--) {
const info = messages[i].info
if (info?.agent === "compaction") {
hasCompactionMessage = true
continue
}
if (info?.agent || info?.model || (info?.modelID && info?.providerID)) {
resolvedInfo = {
agent: info.agent,
model:
info.model ??
(info.providerID && info.modelID
? { providerID: info.providerID, modelID: info.modelID }
: undefined),
tools: info.tools,
}
break
}
}
} catch (err) {
log(`[${HOOK_NAME}] Failed to fetch messages for agent check`, { sessionID, error: String(err) })
}
log(`[${HOOK_NAME}] Agent check`, {
sessionID,
agentName: resolvedInfo?.agent,
skipAgents,
hasCompactionMessage,
})
if (resolvedInfo?.agent && skipAgents.includes(resolvedInfo.agent)) {
log(`[${HOOK_NAME}] Skipped: agent in skipAgents list`, { sessionID, agent: resolvedInfo.agent })
return
}
if (hasCompactionMessage && !resolvedInfo?.agent) {
log(`[${HOOK_NAME}] Skipped: compaction occurred but no agent info resolved`, { sessionID })
return
}
if (isContinuationStopped?.(sessionID)) {
log(`[${HOOK_NAME}] Skipped: continuation stopped for session`, { sessionID })
return
}
startCountdown(sessionID, incompleteCount, tasks.length, resolvedInfo)
return
}
if (event.type === "message.updated") {
const info = props?.info as Record<string, unknown> | undefined
const sessionID = info?.sessionID as string | undefined
const role = info?.role as string | undefined
if (!sessionID) return
if (role === "user") {
const state = sessions.get(sessionID)
if (state?.countdownStartedAt) {
const elapsed = Date.now() - state.countdownStartedAt
if (elapsed < COUNTDOWN_GRACE_PERIOD_MS) {
log(`[${HOOK_NAME}] Ignoring user message in grace period`, { sessionID, elapsed })
return
}
}
if (state) state.abortDetectedAt = undefined
cancelCountdown(sessionID)
}
if (role === "assistant") {
const state = sessions.get(sessionID)
if (state) state.abortDetectedAt = undefined
cancelCountdown(sessionID)
}
return
}
if (event.type === "message.part.updated") {
const info = props?.info as Record<string, unknown> | undefined
const sessionID = info?.sessionID as string | undefined
const role = info?.role as string | undefined
if (sessionID && role === "assistant") {
const state = sessions.get(sessionID)
if (state) state.abortDetectedAt = undefined
cancelCountdown(sessionID)
}
return
}
if (event.type === "tool.execute.before" || event.type === "tool.execute.after") {
const sessionID = props?.sessionID as string | undefined
if (sessionID) {
const state = sessions.get(sessionID)
if (state) state.abortDetectedAt = undefined
cancelCountdown(sessionID)
}
return
}
if (event.type === "session.deleted") {
const sessionInfo = props?.info as { id?: string } | undefined
if (sessionInfo?.id) {
cleanup(sessionInfo.id)
log(`[${HOOK_NAME}] Session deleted: cleaned up`, { sessionID: sessionInfo.id })
}
return
}
}
const cancelAllCountdowns = (): void => {
for (const sessionID of sessions.keys()) {
cancelCountdown(sessionID)
}
log(`[${HOOK_NAME}] All countdowns cancelled`)
}
return {
handler,
markRecovering,
markRecoveryComplete,
cancelAllCountdowns,
}
}

View File

@@ -2,7 +2,7 @@
## OVERVIEW
Tier 1 of three-tier MCP system: 3 built-in remote HTTP MCPs.
Tier 1 of three-tier MCP system: 8 built-in remote HTTP MCPs.
**Three-Tier System**:
1. **Built-in** (this directory): websearch, context7, grep_app

View File

@@ -0,0 +1,96 @@
**Generated:** 2026-02-08T16:45:00+09:00
**Commit:** f2b7b759
**Branch:** dev
## OVERVIEW
Plugin component loading and configuration orchestration. 500+ lines of config merging, migration, and component discovery for Claude Code compatibility.
## STRUCTURE
```
plugin-handlers/
├── config-handler.ts # Main config orchestrator (563 lines) - agent/skill/command loading
├── config-handler.test.ts # Config handler tests (34426 lines)
├── plan-model-inheritance.ts # Plan agent model inheritance logic (657 lines)
├── plan-model-inheritance.test.ts # Inheritance tests (3696 lines)
└── index.ts # Barrel export
```
## CORE FUNCTIONS
**Config Handler (`createConfigHandler`):**
- Loads all plugin components (agents, skills, commands, MCPs)
- Applies permission migrations for compatibility
- Merges user/project/global configurations
- Handles Claude Code plugin integration
**Plan Model Inheritance:**
- Demotes plan agent to prometheus when planner enabled
- Preserves user overrides during migration
- Handles model/variant inheritance from categories
## LOADING PHASES
1. **Plugin Discovery**: Load Claude Code plugins with timeout protection
2. **Component Loading**: Parallel loading of agents, skills, commands
3. **Config Merging**: User → Project → Global → Defaults
4. **Migration**: Legacy config format compatibility
5. **Permission Application**: Tool access control per agent
## KEY FEATURES
**Parallel Loading:**
- Concurrent discovery of user/project/global components
- Timeout protection for plugin loading (default: 10s)
- Error isolation (failed plugins don't break others)
**Migration Support:**
- Agent name mapping (old → new names)
- Permission format conversion
- Config structure updates
**Claude Code Integration:**
- Plugin component loading
- MCP server discovery
- Agent/skill/command compatibility
## CONFIGURATION FLOW
```
User Config → Migration → Merging → Validation → Agent Creation → Permission Application
```
## TESTING COVERAGE
- **Config Handler**: 34426 lines of tests
- **Plan Inheritance**: 3696 lines of tests
- **Migration Logic**: Legacy compatibility verification
- **Parallel Loading**: Timeout and error handling
## USAGE PATTERNS
**Config Handler Creation:**
```typescript
const handler = createConfigHandler({
ctx: { directory: projectDir },
pluginConfig: userConfig,
modelCacheState: cache
});
```
**Plan Demotion:**
```typescript
const demotedPlan = buildPlanDemoteConfig(
prometheusConfig,
userPlanOverrides
);
```
**Component Loading:**
```typescript
const [agents, skills, commands] = await Promise.all([
loadUserAgents(),
loadProjectSkills(),
loadGlobalCommands()
]);
```

View File

@@ -2,7 +2,7 @@
## OVERVIEW
66 cross-cutting utilities. Import via barrel pattern: `import { log, deepMerge } from "../../shared"`
88 cross-cutting utilities. Import via barrel pattern: `import { log, deepMerge } from "../../shared"`
**Categories**: Path resolution, Token truncation, Config parsing, Model resolution, System directives, Tool restrictions

View File

@@ -1,26 +1,38 @@
import { describe, it, expect, beforeEach, afterEach } from "bun:test"
import { describe, it, expect, beforeEach, afterEach, beforeAll, afterAll, mock } from "bun:test"
import { mkdtempSync, writeFileSync, rmSync } from "fs"
import { tmpdir } from "os"
import { join } from "path"
import { fetchAvailableModels, fuzzyMatchModel, getConnectedProviders, __resetModelCache, isModelAvailable } from "./model-availability"
import { fuzzyMatchModel, isModelAvailable } from "./model-name-matcher"
let activeCacheHomeDir: string | null = null
const DEFAULT_CACHE_HOME_DIR = join(tmpdir(), "opencode-test-default-cache")
mock.module("./data-path", () => ({
getDataDir: () => activeCacheHomeDir ?? DEFAULT_CACHE_HOME_DIR,
getOpenCodeStorageDir: () => join(activeCacheHomeDir ?? DEFAULT_CACHE_HOME_DIR, "opencode", "storage"),
getCacheDir: () => activeCacheHomeDir ?? DEFAULT_CACHE_HOME_DIR,
getOmoOpenCodeCacheDir: () => join(activeCacheHomeDir ?? DEFAULT_CACHE_HOME_DIR, "oh-my-opencode"),
getOpenCodeCacheDir: () => join(activeCacheHomeDir ?? DEFAULT_CACHE_HOME_DIR, "opencode"),
}))
describe("fetchAvailableModels", () => {
let tempDir: string
let originalXdgCache: string | undefined
let fetchAvailableModels: (client?: unknown, options?: { connectedProviders?: string[] | null }) => Promise<Set<string>>
let __resetModelCache: () => void
beforeAll(async () => {
;({ fetchAvailableModels } = await import("./available-models-fetcher"))
;({ __resetModelCache } = await import("./model-cache-availability"))
})
beforeEach(() => {
__resetModelCache()
tempDir = mkdtempSync(join(tmpdir(), "opencode-test-"))
originalXdgCache = process.env.XDG_CACHE_HOME
process.env.XDG_CACHE_HOME = tempDir
activeCacheHomeDir = tempDir
})
afterEach(() => {
if (originalXdgCache !== undefined) {
process.env.XDG_CACHE_HOME = originalXdgCache
} else {
delete process.env.XDG_CACHE_HOME
}
activeCacheHomeDir = null
rmSync(tempDir, { recursive: true, force: true })
})

View File

@@ -48,7 +48,6 @@ export function removeSystemReminders(text: string): string {
export const SystemDirectiveTypes = {
TODO_CONTINUATION: "TODO CONTINUATION",
TASK_CONTINUATION: "TASK CONTINUATION",
RALPH_LOOP: "RALPH LOOP",
BOULDER_CONTINUATION: "BOULDER CONTINUATION",
DELEGATION_REQUIRED: "DELEGATION REQUIRED",

View File

@@ -2,7 +2,7 @@
## OVERVIEW
25+ tools across 8 categories. Two patterns: Direct ToolDefinition (static) and Factory Function (context-dependent).
113 tools across 8 categories. Two patterns: Direct ToolDefinition (static) and Factory Function (context-dependent).
**Categories**: LSP (6), AST-Grep (2), Search (2), Session (4), Task (4), Agent delegation (2), Background (2), Skill (3), System (2)

View File

@@ -1,8 +1,8 @@
export {
createBackgroundTask,
createBackgroundOutput,
createBackgroundCancel,
} from "./tools"
export type * from "./types"
export * from "./constants"
export type { BackgroundOutputClient, BackgroundOutputManager, BackgroundCancelClient } from "./tools"

View File

@@ -0,0 +1,116 @@
import { tool, type ToolDefinition } from "@opencode-ai/plugin"
import type { BackgroundCancelClient } from "../types"
import type { BackgroundManager } from "../../../features/background-agent"
import type { BackgroundCancelArgs } from "../types"
import { BACKGROUND_CANCEL_DESCRIPTION } from "../constants"
export function createBackgroundCancel(manager: BackgroundManager, client: BackgroundCancelClient): ToolDefinition {
return tool({
description: BACKGROUND_CANCEL_DESCRIPTION,
args: {
taskId: tool.schema.string().optional().describe("Task ID to cancel (required if all=false)"),
all: tool.schema.boolean().optional().describe("Cancel all running background tasks (default: false)"),
},
async execute(args: BackgroundCancelArgs, toolContext) {
try {
const cancelAll = args.all === true
if (!cancelAll && !args.taskId) {
return `[ERROR] Invalid arguments: Either provide a taskId or set all=true to cancel all running tasks.`
}
if (cancelAll) {
const tasks = manager.getAllDescendantTasks(toolContext.sessionID)
const cancellableTasks = tasks.filter((t: any) => t.status === "running" || t.status === "pending")
if (cancellableTasks.length === 0) {
return `No running or pending background tasks to cancel.`
}
const cancelledInfo: Array<{
id: string
description: string
status: string
sessionID?: string
}> = []
for (const task of cancellableTasks) {
const originalStatus = task.status
const cancelled = await manager.cancelTask(task.id, {
source: "background_cancel",
abortSession: originalStatus === "running",
skipNotification: true,
})
if (!cancelled) continue
cancelledInfo.push({
id: task.id,
description: task.description,
status: originalStatus === "pending" ? "pending" : "running",
sessionID: task.sessionID,
})
}
const tableRows = cancelledInfo
.map(t => `| \`${t.id}\` | ${t.description} | ${t.status} | ${t.sessionID ? `\`${t.sessionID}\`` : "(not started)"} |`)
.join("\n")
const resumableTasks = cancelledInfo.filter(t => t.sessionID)
const resumeSection = resumableTasks.length > 0
? `\n## Continue Instructions
To continue a cancelled task, use:
\`\`\`
task(session_id="<session_id>", prompt="Continue: <your follow-up>")
\`\`\`
Continuable sessions:
${resumableTasks.map(t => `- \`${t.sessionID}\` (${t.description})`).join("\n")}`
: ""
return `Cancelled ${cancelledInfo.length} background task(s):
| Task ID | Description | Status | Session ID |
|---------|-------------|--------|------------|
${tableRows}
${resumeSection}`
}
const task = manager.getTask(args.taskId!)
if (!task) {
return `[ERROR] Task not found: ${args.taskId}`
}
if (task.status !== "running" && task.status !== "pending") {
return `[ERROR] Cannot cancel task: current status is "${task.status}".
Only running or pending tasks can be cancelled.`
}
const cancelled = await manager.cancelTask(task.id, {
source: "background_cancel",
abortSession: task.status === "running",
skipNotification: true,
})
if (!cancelled) {
return `[ERROR] Failed to cancel task: ${task.id}`
}
if (task.status === "pending") {
return `Pending task cancelled successfully
Task ID: ${task.id}
Description: ${task.description}
Status: ${task.status}`
}
return `Task cancelled successfully
Task ID: ${task.id}
Description: ${task.description}
Session ID: ${task.sessionID}
Status: ${task.status}`
} catch (error) {
return `[ERROR] Error cancelling task: ${error instanceof Error ? error.message : String(error)}`
}
},
})
}

View File

@@ -0,0 +1,137 @@
import { tool, type ToolDefinition } from "@opencode-ai/plugin"
import type { BackgroundOutputManager, BackgroundOutputClient } from "../types"
import type { BackgroundOutputArgs } from "../types"
import { BACKGROUND_OUTPUT_DESCRIPTION } from "../constants"
import { formatTaskStatus, formatTaskResult, formatFullSession } from "./formatters"
import { delay } from "./utils"
import { storeToolMetadata } from "../../../features/tool-metadata-store"
import type { BackgroundTask } from "../../../features/background-agent"
import type { ToolContextWithMetadata } from "./utils"
const SISYPHUS_JUNIOR_AGENT = "sisyphus-junior"
type ToolContextWithCallId = ToolContextWithMetadata & {
callID?: string
callId?: string
call_id?: string
}
function resolveToolCallID(ctx: ToolContextWithCallId): string | undefined {
if (typeof ctx.callID === "string" && ctx.callID.trim() !== "") {
return ctx.callID
}
if (typeof ctx.callId === "string" && ctx.callId.trim() !== "") {
return ctx.callId
}
if (typeof ctx.call_id === "string" && ctx.call_id.trim() !== "") {
return ctx.call_id
}
return undefined
}
function formatResolvedTitle(task: BackgroundTask): string {
const label = task.agent === SISYPHUS_JUNIOR_AGENT && task.category
? task.category
: task.agent
return `${label} - ${task.description}`
}
export function createBackgroundOutput(manager: BackgroundOutputManager, client: BackgroundOutputClient): ToolDefinition {
return tool({
description: BACKGROUND_OUTPUT_DESCRIPTION,
args: {
task_id: tool.schema.string().describe("Task ID to get output from"),
block: tool.schema.boolean().optional().describe("Wait for completion (default: false). System notifies when done, so blocking is rarely needed."),
timeout: tool.schema.number().optional().describe("Max wait time in ms (default: 60000, max: 600000)"),
full_session: tool.schema.boolean().optional().describe("Return full session messages with filters (default: false)"),
include_thinking: tool.schema.boolean().optional().describe("Include thinking/reasoning parts in full_session output (default: false)"),
message_limit: tool.schema.number().optional().describe("Max messages to return (capped at 100)"),
since_message_id: tool.schema.string().optional().describe("Return messages after this message ID (exclusive)"),
include_tool_results: tool.schema.boolean().optional().describe("Include tool results in full_session output (default: false)"),
thinking_max_chars: tool.schema.number().optional().describe("Max characters for thinking content (default: 2000)"),
},
async execute(args: BackgroundOutputArgs, toolContext) {
try {
const ctx = toolContext as ToolContextWithCallId
const task = manager.getTask(args.task_id)
if (!task) {
return `Task not found: ${args.task_id}`
}
const resolvedTitle = formatResolvedTitle(task)
const meta = {
title: resolvedTitle,
metadata: {
task_id: task.id,
agent: task.agent,
category: task.category,
description: task.description,
sessionId: task.sessionID ?? "pending",
} as Record<string, unknown>,
}
await ctx.metadata?.(meta)
const callID = resolveToolCallID(ctx)
if (callID) {
storeToolMetadata(ctx.sessionID, callID, meta)
}
if (args.full_session === true) {
return await formatFullSession(task, client, {
includeThinking: args.include_thinking === true,
messageLimit: args.message_limit,
sinceMessageId: args.since_message_id,
includeToolResults: args.include_tool_results === true,
thinkingMaxChars: args.thinking_max_chars,
})
}
const shouldBlock = args.block === true
const timeoutMs = Math.min(args.timeout ?? 60000, 600000)
// Already completed: return result immediately (regardless of block flag)
if (task.status === "completed") {
return await formatTaskResult(task, client)
}
// Error or cancelled: return status immediately
if (task.status === "error" || task.status === "cancelled") {
return formatTaskStatus(task)
}
// Non-blocking and still running: return status
if (!shouldBlock) {
return formatTaskStatus(task)
}
// Blocking: poll until completion or timeout
const startTime = Date.now()
while (Date.now() - startTime < timeoutMs) {
await delay(1000)
const currentTask = manager.getTask(args.task_id)
if (!currentTask) {
return `Task was deleted: ${args.task_id}`
}
if (currentTask.status === "completed") {
return await formatTaskResult(currentTask, client)
}
if (currentTask.status === "error" || currentTask.status === "cancelled") {
return formatTaskStatus(currentTask)
}
}
// Timeout exceeded: return current status
const finalTask = manager.getTask(args.task_id)
if (!finalTask) {
return `Task was deleted: ${args.task_id}`
}
return `Timeout exceeded (${timeoutMs}ms). Task still ${finalTask.status}.\n\n${formatTaskStatus(finalTask)}`
} catch (error) {
return `Error getting output: ${error instanceof Error ? error.message : String(error)}`
}
},
})
}

View File

@@ -0,0 +1,105 @@
import { tool, type ToolDefinition } from "@opencode-ai/plugin"
import type { BackgroundManager } from "../../../features/background-agent"
import type { BackgroundTaskArgs } from "../types"
import { BACKGROUND_TASK_DESCRIPTION } from "../constants"
import { findNearestMessageWithFields, findFirstMessageWithAgent, MESSAGE_STORAGE } from "../../../features/hook-message-injector"
import { getSessionAgent } from "../../../features/claude-code-session-state"
import { log } from "../../../shared/logger"
import { storeToolMetadata } from "../../../features/tool-metadata-store"
import { getMessageDir, delay, type ToolContextWithMetadata } from "./utils"
export function createBackgroundTask(manager: BackgroundManager): ToolDefinition {
return tool({
description: BACKGROUND_TASK_DESCRIPTION,
args: {
description: tool.schema.string().describe("Short task description (shown in status)"),
prompt: tool.schema.string().describe("Full detailed prompt for the agent"),
agent: tool.schema.string().describe("Agent type to use (any registered agent)"),
},
async execute(args: BackgroundTaskArgs, toolContext) {
const ctx = toolContext as ToolContextWithMetadata
if (!args.agent || args.agent.trim() === "") {
return `[ERROR] Agent parameter is required. Please specify which agent to use (e.g., "explore", "librarian", "build", etc.)`
}
try {
const messageDir = getMessageDir(ctx.sessionID)
const prevMessage = messageDir ? findNearestMessageWithFields(messageDir) : null
const firstMessageAgent = messageDir ? findFirstMessageWithAgent(messageDir) : null
const sessionAgent = getSessionAgent(ctx.sessionID)
const parentAgent = ctx.agent ?? sessionAgent ?? firstMessageAgent ?? prevMessage?.agent
log("[background_task] parentAgent resolution", {
sessionID: ctx.sessionID,
ctxAgent: ctx.agent,
sessionAgent,
firstMessageAgent,
prevMessageAgent: prevMessage?.agent,
resolvedParentAgent: parentAgent,
})
const parentModel = prevMessage?.model?.providerID && prevMessage?.model?.modelID
? {
providerID: prevMessage.model.providerID,
modelID: prevMessage.model.modelID,
...(prevMessage.model.variant ? { variant: prevMessage.model.variant } : {})
}
: undefined
const task = await manager.launch({
description: args.description,
prompt: args.prompt,
agent: args.agent.trim(),
parentSessionID: ctx.sessionID,
parentMessageID: ctx.messageID,
parentModel,
parentAgent,
})
const WAIT_FOR_SESSION_INTERVAL_MS = 50
const WAIT_FOR_SESSION_TIMEOUT_MS = 30000
const waitStart = Date.now()
let sessionId = task.sessionID
while (!sessionId && Date.now() - waitStart < WAIT_FOR_SESSION_TIMEOUT_MS) {
if (ctx.abort?.aborted) {
await manager.cancelTask(task.id)
return `Task aborted and cancelled while waiting for session to start.\n\nTask ID: ${task.id}`
}
await delay(WAIT_FOR_SESSION_INTERVAL_MS)
const updated = manager.getTask(task.id)
if (!updated || updated.status === "error") {
return `Task ${!updated ? "was deleted" : `entered error state`}.\n\nTask ID: ${task.id}`
}
sessionId = updated?.sessionID
}
const bgMeta = {
title: args.description,
metadata: { sessionId: sessionId ?? "pending" } as Record<string, unknown>,
}
await ctx.metadata?.(bgMeta)
const callID = (ctx as any).callID as string | undefined
if (callID) {
storeToolMetadata(ctx.sessionID, callID, bgMeta)
}
return `Background task launched successfully.
Task ID: ${task.id}
Session ID: ${sessionId ?? "pending"}
Description: ${task.description}
Agent: ${task.agent}
Status: ${task.status}
The system will notify you when the task completes.
Use \`background_output\` tool with task_id="${task.id}" to check progress:
- block=false (default): Check status immediately - returns full status info
- block=true: Wait for completion (rarely needed since system notifies)`
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
return `[ERROR] Failed to launch background task: ${message}`
}
},
})
}

View File

@@ -0,0 +1,311 @@
import type { BackgroundTask } from "../../../features/background-agent"
import type { BackgroundOutputClient } from "../types"
import { formatDuration, truncateText, formatMessageTime } from "./utils"
import { extractMessages, getErrorMessage, type BackgroundOutputMessagesResult, type FullSessionMessage, extractToolResultText } from "./message-processing"
import { consumeNewMessages } from "../../../shared/session-cursor"
const MAX_MESSAGE_LIMIT = 100
const THINKING_MAX_CHARS = 2000
export function formatTaskStatus(task: BackgroundTask): string {
let duration: string
if (task.status === "pending" && task.queuedAt) {
duration = formatDuration(task.queuedAt, undefined)
} else if (task.startedAt) {
duration = formatDuration(task.startedAt, task.completedAt)
} else {
duration = "N/A"
}
const promptPreview = truncateText(task.prompt, 500)
let progressSection = ""
if (task.progress?.lastTool) {
progressSection = `\n| Last tool | ${task.progress.lastTool} |`
}
let lastMessageSection = ""
if (task.progress?.lastMessage) {
const truncated = truncateText(task.progress.lastMessage, 500)
const messageTime = task.progress.lastMessageAt
? task.progress.lastMessageAt.toISOString()
: "N/A"
lastMessageSection = `
## Last Message (${messageTime})
\`\`\`
${truncated}
\`\`\``
}
let statusNote = ""
if (task.status === "pending") {
statusNote = `
> **Queued**: Task is waiting for a concurrency slot to become available.`
} else if (task.status === "running") {
statusNote = `
> **Note**: No need to wait explicitly - the system will notify you when this task completes.`
} else if (task.status === "error") {
statusNote = `
> **Failed**: The task encountered an error. Check the last message for details.`
}
const durationLabel = task.status === "pending" ? "Queued for" : "Duration"
return `# Task Status
| Field | Value |
|-------|-------|
| Task ID | \`${task.id}\` |
| Description | ${task.description} |
| Agent | ${task.agent} |
| Status | **${task.status}** |
| ${durationLabel} | ${duration} |
| Session ID | \`${task.sessionID}\` |${progressSection}
${statusNote}
## Original Prompt
\`\`\`
${promptPreview}
\`\`\`${lastMessageSection}`
}
export async function formatTaskResult(task: BackgroundTask, client: BackgroundOutputClient): Promise<string> {
if (!task.sessionID) {
return `Error: Task has no sessionID`
}
const messagesResult: BackgroundOutputMessagesResult = await client.session.messages({
path: { id: task.sessionID },
})
const errorMessage = getErrorMessage(messagesResult)
if (errorMessage) {
return `Error fetching messages: ${errorMessage}`
}
const messages = extractMessages(messagesResult)
if (!Array.isArray(messages) || messages.length === 0) {
return `Task Result
Task ID: ${task.id}
Description: ${task.description}
Duration: ${formatDuration(task.startedAt ?? new Date(), task.completedAt)}
Session ID: ${task.sessionID}
---
(No messages found)`
}
// Include both assistant messages AND tool messages
// Tool results (grep, glob, bash output) come from role "tool"
const relevantMessages = messages.filter(
(m) => m.info?.role === "assistant" || m.info?.role === "tool"
)
if (relevantMessages.length === 0) {
return `Task Result
Task ID: ${task.id}
Description: ${task.description}
Duration: ${formatDuration(task.startedAt ?? new Date(), task.completedAt)}
Session ID: ${task.sessionID}
---
(No assistant or tool response found)`
}
// Sort by time ascending (oldest first) to process messages in order
const sortedMessages = [...relevantMessages].sort((a, b) => {
const timeA = String((a as { info?: { time?: string } }).info?.time ?? "")
const timeB = String((b as { info?: { time?: string } }).info?.time ?? "")
return timeA.localeCompare(timeB)
})
const newMessages = consumeNewMessages(task.sessionID, sortedMessages)
if (newMessages.length === 0) {
const duration = formatDuration(task.startedAt ?? new Date(), task.completedAt)
return `Task Result
Task ID: ${task.id}
Description: ${task.description}
Duration: ${duration}
Session ID: ${task.sessionID}
---
(No new output since last check)`
}
// Extract content from ALL messages, not just the last one
// Tool results may be in earlier messages while the final message is empty
const extractedContent: string[] = []
for (const message of newMessages) {
for (const part of message.parts ?? []) {
// Handle both "text" and "reasoning" parts (thinking models use "reasoning")
if ((part.type === "text" || part.type === "reasoning") && part.text) {
extractedContent.push(part.text)
} else if (part.type === "tool_result") {
// Tool results contain the actual output from tool calls
const toolResult = part as { content?: string | Array<{ type: string; text?: string }> }
if (typeof toolResult.content === "string" && toolResult.content) {
extractedContent.push(toolResult.content)
} else if (Array.isArray(toolResult.content)) {
// Handle array of content blocks
for (const block of toolResult.content) {
// Handle both "text" and "reasoning" parts (thinking models use "reasoning")
if ((block.type === "text" || block.type === "reasoning") && block.text) {
extractedContent.push(block.text)
}
}
}
}
}
}
const textContent = extractedContent
.filter((text) => text.length > 0)
.join("\n\n")
const duration = formatDuration(task.startedAt ?? new Date(), task.completedAt)
return `Task Result
Task ID: ${task.id}
Description: ${task.description}
Duration: ${duration}
Session ID: ${task.sessionID}
---
${textContent || "(No text output)"}`
}
export async function formatFullSession(
task: BackgroundTask,
client: BackgroundOutputClient,
options: {
includeThinking: boolean
messageLimit?: number
sinceMessageId?: string
includeToolResults: boolean
thinkingMaxChars?: number
}
): Promise<string> {
if (!task.sessionID) {
return formatTaskStatus(task)
}
const messagesResult: BackgroundOutputMessagesResult = await client.session.messages({
path: { id: task.sessionID },
})
const errorMessage = getErrorMessage(messagesResult)
if (errorMessage) {
return `Error fetching messages: ${errorMessage}`
}
const rawMessages = extractMessages(messagesResult)
if (!Array.isArray(rawMessages)) {
return "Error fetching messages: invalid response"
}
const sortedMessages = [...(rawMessages as FullSessionMessage[])].sort((a, b) => {
const timeA = String(a.info?.time ?? "")
const timeB = String(b.info?.time ?? "")
return timeA.localeCompare(timeB)
})
let filteredMessages = sortedMessages
if (options.sinceMessageId) {
const index = filteredMessages.findIndex((message) => message.id === options.sinceMessageId)
if (index === -1) {
return `Error: since_message_id not found: ${options.sinceMessageId}`
}
filteredMessages = filteredMessages.slice(index + 1)
}
const includeThinking = options.includeThinking
const includeToolResults = options.includeToolResults
const thinkingMaxChars = options.thinkingMaxChars ?? THINKING_MAX_CHARS
const normalizedMessages: FullSessionMessage[] = []
for (const message of filteredMessages) {
const parts = (message.parts ?? []).filter((part) => {
if (part.type === "thinking" || part.type === "reasoning") {
return includeThinking
}
if (part.type === "tool_result") {
return includeToolResults
}
return part.type === "text"
})
if (parts.length === 0) {
continue
}
normalizedMessages.push({ ...message, parts })
}
const limit = typeof options.messageLimit === "number"
? Math.min(options.messageLimit, MAX_MESSAGE_LIMIT)
: undefined
const hasMore = limit !== undefined && normalizedMessages.length > limit
const visibleMessages = limit !== undefined
? normalizedMessages.slice(0, limit)
: normalizedMessages
const lines: string[] = []
lines.push("# Full Session Output")
lines.push("")
lines.push(`Task ID: ${task.id}`)
lines.push(`Description: ${task.description}`)
lines.push(`Status: ${task.status}`)
lines.push(`Session ID: ${task.sessionID}`)
lines.push(`Total messages: ${normalizedMessages.length}`)
lines.push(`Returned: ${visibleMessages.length}`)
lines.push(`Has more: ${hasMore ? "true" : "false"}`)
lines.push("")
lines.push("## Messages")
if (visibleMessages.length === 0) {
lines.push("")
lines.push("(No messages found)")
return lines.join("\n")
}
for (const message of visibleMessages) {
const role = message.info?.role ?? "unknown"
const agent = message.info?.agent ? ` (${message.info.agent})` : ""
const time = formatMessageTime(message.info?.time)
const idLabel = message.id ? ` id=${message.id}` : ""
lines.push("")
lines.push(`[${role}${agent}] ${time}${idLabel}`)
for (const part of message.parts ?? []) {
if (part.type === "text" && part.text) {
lines.push(part.text.trim())
} else if (part.type === "thinking" && part.thinking) {
lines.push(`[thinking] ${truncateText(part.thinking, thinkingMaxChars)}`)
} else if (part.type === "reasoning" && part.text) {
lines.push(`[thinking] ${truncateText(part.text, thinkingMaxChars)}`)
} else if (part.type === "tool_result") {
const toolTexts = extractToolResultText(part)
for (const toolText of toolTexts) {
lines.push(`[tool result] ${toolText}`)
}
}
}
}
return lines.join("\n")
}

View File

@@ -0,0 +1,75 @@
export type BackgroundOutputMessage = {
info?: { role?: string; time?: string | { created?: number }; agent?: string }
parts?: Array<{
type?: string
text?: string
content?: string | Array<{ type: string; text?: string }>
name?: string
}>
}
export type BackgroundOutputMessagesResult =
| { data?: BackgroundOutputMessage[]; error?: unknown }
| BackgroundOutputMessage[]
export type FullSessionMessagePart = {
type?: string
text?: string
thinking?: string
content?: string | Array<{ type?: string; text?: string }>
output?: string
}
export type FullSessionMessage = {
id?: string
info?: { role?: string; time?: string; agent?: string }
parts?: FullSessionMessagePart[]
}
export function getErrorMessage(value: BackgroundOutputMessagesResult): string | null {
if (Array.isArray(value)) return null
if (value.error === undefined || value.error === null) return null
if (typeof value.error === "string" && value.error.length > 0) return value.error
return String(value.error)
}
export function isSessionMessage(value: unknown): value is {
info?: { role?: string; time?: string }
parts?: Array<{
type?: string
text?: string
content?: string | Array<{ type: string; text?: string }>
name?: string
}>
} {
return typeof value === "object" && value !== null
}
export function extractMessages(value: BackgroundOutputMessagesResult): BackgroundOutputMessage[] {
if (Array.isArray(value)) {
return value.filter(isSessionMessage)
}
if (Array.isArray(value.data)) {
return value.data.filter(isSessionMessage)
}
return []
}
export function extractToolResultText(part: FullSessionMessagePart): string[] {
if (typeof part.content === "string" && part.content.length > 0) {
return [part.content]
}
if (Array.isArray(part.content)) {
const blocks = part.content
.filter((block) => (block.type === "text" || block.type === "reasoning") && block.text)
.map((block) => block.text as string)
if (blocks.length > 0) return blocks
}
if (part.output && part.output.length > 0) {
return [part.output]
}
return []
}

View File

@@ -0,0 +1,65 @@
import { existsSync, readdirSync } from "node:fs"
import { join } from "node:path"
import { MESSAGE_STORAGE } from "../../../features/hook-message-injector"
export function getMessageDir(sessionID: string): string | null {
if (!existsSync(MESSAGE_STORAGE)) return null
const directPath = join(MESSAGE_STORAGE, sessionID)
if (existsSync(directPath)) return directPath
for (const dir of readdirSync(MESSAGE_STORAGE)) {
const sessionPath = join(MESSAGE_STORAGE, dir, sessionID)
if (existsSync(sessionPath)) return sessionPath
}
return null
}
export function formatDuration(start: Date, end?: Date): string {
const duration = (end ?? new Date()).getTime() - start.getTime()
const seconds = Math.floor(duration / 1000)
const minutes = Math.floor(seconds / 60)
const hours = Math.floor(minutes / 60)
if (hours > 0) {
return `${hours}h ${minutes % 60}m ${seconds % 60}s`
} else if (minutes > 0) {
return `${minutes}m ${seconds % 60}s`
} else {
return `${seconds}s`
}
}
export function truncateText(text: string, maxLength: number): string {
if (text.length <= maxLength) return text
return text.slice(0, maxLength) + "..."
}
export function delay(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms))
}
export function formatMessageTime(value: unknown): string {
if (typeof value === "string") {
const date = new Date(value)
return Number.isNaN(date.getTime()) ? value : date.toISOString()
}
if (typeof value === "object" && value !== null) {
if ("created" in value) {
const created = (value as { created?: number }).created
if (typeof created === "number") {
return new Date(created).toISOString()
}
}
}
return "Unknown time"
}
export type ToolContextWithMetadata = {
sessionID: string
messageID: string
agent: string
abort: AbortSignal
metadata?: (input: { title?: string; metadata?: Record<string, unknown> }) => void
}

View File

@@ -1,7 +1,11 @@
/// <reference types="bun-types" />
import { describe, test, expect } from "bun:test"
import { createBackgroundCancel, createBackgroundOutput } from "./tools"
import type { BackgroundManager, BackgroundTask } from "../../features/background-agent"
import type { ToolContext } from "@opencode-ai/plugin/tool"
import type { BackgroundCancelClient, BackgroundOutputManager, BackgroundOutputClient } from "./tools"
import { consumeToolMetadata, clearPendingStore } from "../../features/tool-metadata-store"
const projectDir = "/Users/yeongyu/local-workspaces/oh-my-opencode"
@@ -49,6 +53,59 @@ function createTask(overrides: Partial<BackgroundTask> = {}): BackgroundTask {
}
describe("background_output full_session", () => {
test("resolves task_id into title metadata", async () => {
// #given
clearPendingStore()
const task = createTask({
id: "task-1",
agent: "explore",
description: "Find how task output is rendered",
status: "running",
})
const manager = createMockManager(task)
const client = createMockClient({})
const tool = createBackgroundOutput(manager, client)
const ctxWithCallId = {
...mockContext,
callID: "call-1",
} as unknown as ToolContext
// #when
await tool.execute({ task_id: "task-1" }, ctxWithCallId)
// #then
const restored = consumeToolMetadata("test-session", "call-1")
expect(restored?.title).toBe("explore - Find how task output is rendered")
})
test("shows category instead of agent for sisyphus-junior", async () => {
// #given
clearPendingStore()
const task = createTask({
id: "task-1",
agent: "sisyphus-junior",
category: "quick",
description: "Fix flaky test",
status: "running",
})
const manager = createMockManager(task)
const client = createMockClient({})
const tool = createBackgroundOutput(manager, client)
const ctxWithCallId = {
...mockContext,
callID: "call-1",
} as unknown as ToolContext
// #when
await tool.execute({ task_id: "task-1" }, ctxWithCallId)
// #then
const restored = consumeToolMetadata("test-session", "call-1")
expect(restored?.title).toBe("quick - Fix flaky test")
})
test("includes thinking and tool results when enabled", async () => {
// #given
const task = createTask()

View File

@@ -20,3 +20,53 @@ export interface BackgroundCancelArgs {
taskId?: string
all?: boolean
}
export type BackgroundOutputMessage = {
info?: { role?: string; time?: string | { created?: number }; agent?: string }
parts?: Array<{
type?: string
text?: string
content?: string | Array<{ type: string; text?: string }>
name?: string
}>
}
export type BackgroundOutputMessagesResult =
| { data?: BackgroundOutputMessage[]; error?: unknown }
| BackgroundOutputMessage[]
export type BackgroundOutputClient = {
session: {
messages: (args: { path: { id: string } }) => Promise<BackgroundOutputMessagesResult>
}
}
export type BackgroundCancelClient = {
session: {
abort: (args: { path: { id: string } }) => Promise<unknown>
}
}
export type BackgroundOutputManager = Pick<import("../../features/background-agent").BackgroundManager, "getTask">
export type FullSessionMessagePart = {
type?: string
text?: string
thinking?: string
content?: string | Array<{ type?: string; text?: string }>
output?: string
}
export type FullSessionMessage = {
id?: string
info?: { role?: string; time?: string; agent?: string }
parts?: FullSessionMessagePart[]
}
export type ToolContextWithMetadata = {
sessionID: string
messageID: string
agent: string
abort: AbortSignal
metadata?: (input: { title?: string; metadata?: Record<string, unknown> }) => void
}

View File

@@ -0,0 +1,83 @@
import type { CallOmoAgentArgs } from "./types"
import type { BackgroundManager } from "../../features/background-agent"
import { log } from "../../shared"
import { consumeNewMessages } from "../../shared/session-cursor"
import { findFirstMessageWithAgent, findNearestMessageWithFields } from "../../features/hook-message-injector"
import { getSessionAgent } from "../../features/claude-code-session-state"
import { getMessageDir } from "./message-dir"
export async function executeBackground(
args: CallOmoAgentArgs,
toolContext: {
sessionID: string
messageID: string
agent: string
abort: AbortSignal
metadata?: (input: { title?: string; metadata?: Record<string, unknown> }) => void
},
manager: BackgroundManager
): Promise<string> {
try {
const messageDir = getMessageDir(toolContext.sessionID)
const prevMessage = messageDir ? findNearestMessageWithFields(messageDir) : null
const firstMessageAgent = messageDir ? findFirstMessageWithAgent(messageDir) : null
const sessionAgent = getSessionAgent(toolContext.sessionID)
const parentAgent = toolContext.agent ?? sessionAgent ?? firstMessageAgent ?? prevMessage?.agent
log("[call_omo_agent] parentAgent resolution", {
sessionID: toolContext.sessionID,
messageDir,
ctxAgent: toolContext.agent,
sessionAgent,
firstMessageAgent,
prevMessageAgent: prevMessage?.agent,
resolvedParentAgent: parentAgent,
})
const task = await manager.launch({
description: args.description,
prompt: args.prompt,
agent: args.subagent_type,
parentSessionID: toolContext.sessionID,
parentMessageID: toolContext.messageID,
parentAgent,
})
const WAIT_FOR_SESSION_INTERVAL_MS = 50
const WAIT_FOR_SESSION_TIMEOUT_MS = 30000
const waitStart = Date.now()
let sessionId = task.sessionID
while (!sessionId && Date.now() - waitStart < WAIT_FOR_SESSION_TIMEOUT_MS) {
if (toolContext.abort?.aborted) {
return `Task aborted while waiting for session to start.\n\nTask ID: ${task.id}`
}
const updated = manager.getTask(task.id)
if (updated?.status === "error" || updated?.status === "cancelled") {
return `Task failed to start (status: ${updated.status}).\n\nTask ID: ${task.id}`
}
await new Promise(resolve => setTimeout(resolve, WAIT_FOR_SESSION_INTERVAL_MS))
sessionId = manager.getTask(task.id)?.sessionID
}
await toolContext.metadata?.({
title: args.description,
metadata: { sessionId: sessionId ?? "pending" },
})
return `Background agent task launched successfully.
Task ID: ${task.id}
Session ID: ${sessionId ?? "pending"}
Description: ${task.description}
Agent: ${task.agent} (subagent)
Status: ${task.status}
The system will notify you when the task completes.
Use \`background_output\` tool with task_id="${task.id}" to check progress:
- block=false (default): Check status immediately - returns full status info
- block=true: Wait for completion (rarely needed since system notifies)`
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
return `Failed to launch background agent task: ${message}`
}
}

View File

@@ -0,0 +1,67 @@
import type { PluginInput } from "@opencode-ai/plugin"
import { log } from "../../shared"
export async function waitForCompletion(
sessionID: string,
toolContext: {
sessionID: string
messageID: string
agent: string
abort: AbortSignal
metadata?: (input: { title?: string; metadata?: Record<string, unknown> }) => void
},
ctx: PluginInput
): Promise<void> {
log(`[call_omo_agent] Polling for completion...`)
// Poll for session completion
const POLL_INTERVAL_MS = 500
const MAX_POLL_TIME_MS = 5 * 60 * 1000 // 5 minutes max
const pollStart = Date.now()
let lastMsgCount = 0
let stablePolls = 0
const STABILITY_REQUIRED = 3
while (Date.now() - pollStart < MAX_POLL_TIME_MS) {
// Check if aborted
if (toolContext.abort?.aborted) {
log(`[call_omo_agent] Aborted by user`)
throw new Error("Task aborted.")
}
await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL_MS))
// Check session status
const statusResult = await ctx.client.session.status()
const allStatuses = (statusResult.data ?? {}) as Record<string, { type: string }>
const sessionStatus = allStatuses[sessionID]
// If session is actively running, reset stability counter
if (sessionStatus && sessionStatus.type !== "idle") {
stablePolls = 0
lastMsgCount = 0
continue
}
// Session is idle - check message stability
const messagesCheck = await ctx.client.session.messages({ path: { id: sessionID } })
const msgs = ((messagesCheck as { data?: unknown }).data ?? messagesCheck) as Array<unknown>
const currentMsgCount = msgs.length
if (currentMsgCount > 0 && currentMsgCount === lastMsgCount) {
stablePolls++
if (stablePolls >= STABILITY_REQUIRED) {
log(`[call_omo_agent] Session complete, ${currentMsgCount} messages`)
break
}
} else {
stablePolls = 0
lastMsgCount = currentMsgCount
}
}
if (Date.now() - pollStart >= MAX_POLL_TIME_MS) {
log(`[call_omo_agent] Timeout reached`)
throw new Error("Agent task timed out after 5 minutes.")
}
}

View File

@@ -0,0 +1,18 @@
import { existsSync, readdirSync } from "node:fs"
import { join } from "node:path"
import { MESSAGE_STORAGE } from "../../features/hook-message-injector"
export function getMessageDir(sessionID: string): string | null {
if (!sessionID.startsWith("ses_")) return null
if (!existsSync(MESSAGE_STORAGE)) return null
const directPath = join(MESSAGE_STORAGE, sessionID)
if (existsSync(directPath)) return directPath
for (const dir of readdirSync(MESSAGE_STORAGE)) {
const sessionPath = join(MESSAGE_STORAGE, dir, sessionID)
if (existsSync(sessionPath)) return sessionPath
}
return null
}

View File

@@ -0,0 +1,84 @@
import type { PluginInput } from "@opencode-ai/plugin"
import { log } from "../../shared"
import { consumeNewMessages } from "../../shared/session-cursor"
export async function processMessages(
sessionID: string,
ctx: PluginInput
): Promise<string> {
const messagesResult = await ctx.client.session.messages({
path: { id: sessionID },
})
if (messagesResult.error) {
log(`[call_omo_agent] Messages error:`, messagesResult.error)
throw new Error(`Failed to get messages: ${messagesResult.error}`)
}
const messages = messagesResult.data
log(`[call_omo_agent] Got ${messages.length} messages`)
// Include both assistant messages AND tool messages
// Tool results (grep, glob, bash output) come from role "tool"
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const relevantMessages = messages.filter(
(m: any) => m.info?.role === "assistant" || m.info?.role === "tool"
)
if (relevantMessages.length === 0) {
log(`[call_omo_agent] No assistant or tool messages found`)
log(`[call_omo_agent] All messages:`, JSON.stringify(messages, null, 2))
throw new Error("No assistant or tool response found")
}
log(`[call_omo_agent] Found ${relevantMessages.length} relevant messages`)
// Sort by time ascending (oldest first) to process messages in order
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const sortedMessages = [...relevantMessages].sort((a: any, b: any) => {
const timeA = a.info?.time?.created ?? 0
const timeB = b.info?.time?.created ?? 0
return timeA - timeB
})
const newMessages = consumeNewMessages(sessionID, sortedMessages)
if (newMessages.length === 0) {
return "No new output since last check."
}
// Extract content from ALL messages, not just the last one
// Tool results may be in earlier messages while the final message is empty
const extractedContent: string[] = []
for (const message of newMessages) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
for (const part of (message as any).parts ?? []) {
// Handle both "text" and "reasoning" parts (thinking models use "reasoning")
if ((part.type === "text" || part.type === "reasoning") && part.text) {
extractedContent.push(part.text)
} else if (part.type === "tool_result") {
// Tool results contain the actual output from tool calls
const toolResult = part as { content?: string | Array<{ type: string; text?: string }> }
if (typeof toolResult.content === "string" && toolResult.content) {
extractedContent.push(toolResult.content)
} else if (Array.isArray(toolResult.content)) {
// Handle array of content blocks
for (const block of toolResult.content) {
if ((block.type === "text" || block.type === "reasoning") && block.text) {
extractedContent.push(block.text)
}
}
}
}
}
}
const responseText = extractedContent
.filter((text) => text.length > 0)
.join("\n\n")
log(`[call_omo_agent] Got response, length: ${responseText.length}`)
return responseText
}

View File

@@ -0,0 +1,70 @@
import type { CallOmoAgentArgs } from "./types"
import type { PluginInput } from "@opencode-ai/plugin"
import { log } from "../../shared"
export async function createOrGetSession(
args: CallOmoAgentArgs,
toolContext: {
sessionID: string
messageID: string
agent: string
abort: AbortSignal
metadata?: (input: { title?: string; metadata?: Record<string, unknown> }) => void
},
ctx: PluginInput
): Promise<{ sessionID: string; isNew: boolean }> {
if (args.session_id) {
log(`[call_omo_agent] Using existing session: ${args.session_id}`)
const sessionResult = await ctx.client.session.get({
path: { id: args.session_id },
})
if (sessionResult.error) {
log(`[call_omo_agent] Session get error:`, sessionResult.error)
throw new Error(`Failed to get existing session: ${sessionResult.error}`)
}
return { sessionID: args.session_id, isNew: false }
} else {
log(`[call_omo_agent] Creating new session with parent: ${toolContext.sessionID}`)
const parentSession = await ctx.client.session.get({
path: { id: toolContext.sessionID },
}).catch((err) => {
log(`[call_omo_agent] Failed to get parent session:`, err)
return null
})
log(`[call_omo_agent] Parent session dir: ${parentSession?.data?.directory}, fallback: ${ctx.directory}`)
const parentDirectory = parentSession?.data?.directory ?? ctx.directory
const createResult = await ctx.client.session.create({
body: {
parentID: toolContext.sessionID,
title: `${args.description} (@${args.subagent_type} subagent)`,
permission: [
{ permission: "question", action: "deny" as const, pattern: "*" },
],
} as any,
query: {
directory: parentDirectory,
},
})
if (createResult.error) {
log(`[call_omo_agent] Session create error:`, createResult.error)
const errorStr = String(createResult.error)
if (errorStr.toLowerCase().includes("unauthorized")) {
throw new Error(`Failed to create session (Unauthorized). This may be due to:
1. OAuth token restrictions (e.g., Claude Code credentials are restricted to Claude Code only)
2. Provider authentication issues
3. Session permission inheritance problems
Try using a different provider or API key authentication.
Original error: ${createResult.error}`)
}
throw new Error(`Failed to create session: ${createResult.error}`)
}
const sessionID = createResult.data.id
log(`[call_omo_agent] Created session: ${sessionID}`)
return { sessionID, isNew: true }
}
}

View File

@@ -0,0 +1,59 @@
import type { CallOmoAgentArgs } from "./types"
import type { PluginInput } from "@opencode-ai/plugin"
import { log } from "../../shared"
import { getAgentToolRestrictions } from "../../shared"
import { createOrGetSession } from "./session-creator"
import { waitForCompletion } from "./completion-poller"
import { processMessages } from "./message-processor"
export async function executeSync(
args: CallOmoAgentArgs,
toolContext: {
sessionID: string
messageID: string
agent: string
abort: AbortSignal
metadata?: (input: { title?: string; metadata?: Record<string, unknown> }) => void
},
ctx: PluginInput
): Promise<string> {
const { sessionID } = await createOrGetSession(args, toolContext, ctx)
await toolContext.metadata?.({
title: args.description,
metadata: { sessionId: sessionID },
})
log(`[call_omo_agent] Sending prompt to session ${sessionID}`)
log(`[call_omo_agent] Prompt text:`, args.prompt.substring(0, 100))
try {
await (ctx.client.session as any).promptAsync({
path: { id: sessionID },
body: {
agent: args.subagent_type,
tools: {
...getAgentToolRestrictions(args.subagent_type),
task: false,
},
parts: [{ type: "text", text: args.prompt }],
},
})
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
log(`[call_omo_agent] Prompt error:`, errorMessage)
if (errorMessage.includes("agent.name") || errorMessage.includes("undefined")) {
return `Error: Agent "${args.subagent_type}" not found. Make sure the agent is registered in your opencode.json or provided by a plugin.\n\n<task_metadata>\nsession_id: ${sessionID}\n</task_metadata>`
}
return `Error: Failed to send prompt: ${errorMessage}\n\n<task_metadata>\nsession_id: ${sessionID}\n</task_metadata>`
}
await waitForCompletion(sessionID, toolContext, ctx)
const responseText = await processMessages(sessionID, ctx)
const output =
responseText + "\n\n" + ["<task_metadata>", `session_id: ${sessionID}`, "</task_metadata>"].join("\n")
return output
}

View File

@@ -25,3 +25,10 @@ export interface CallOmoAgentSyncResult {
}
output: string
}
export type ToolContextWithMetadata = {
sessionID: string
messageID: string
agent: string
abort: AbortSignal
metadata?: (input: { title?: string; metadata?: Record<string, unknown> }) => void
}

View File

@@ -0,0 +1,21 @@
import type { GitMasterConfig, BrowserAutomationProvider } from "../../config/schema"
import { resolveMultipleSkillsAsync } from "../../features/opencode-skill-loader/skill-content"
import { discoverSkills } from "../../features/opencode-skill-loader"
export async function resolveSkillContent(
skills: string[],
options: { gitMasterConfig?: GitMasterConfig; browserProvider?: BrowserAutomationProvider, disabledSkills?: Set<string> }
): Promise<{ content: string | undefined; error: string | null }> {
if (skills.length === 0) {
return { content: undefined, error: null }
}
const { resolved, notFound } = await resolveMultipleSkillsAsync(skills, options)
if (notFound.length > 0) {
const allSkills = await discoverSkills({ includeClaudeCodePaths: true })
const available = allSkills.map(s => s.name).join(", ")
return { content: undefined, error: `Skills not found: ${notFound.join(", ")}. Available: ${available}` }
}
return { content: Array.from(resolved.values()).join("\n\n"), error: null }
}

View File

@@ -34,6 +34,10 @@ export interface ToolContextWithMetadata {
* but present at runtime via spread in fromPlugin()). Used for metadata store keying.
*/
callID?: string
/** @deprecated OpenCode internal naming may vary across versions */
callId?: string
/** @deprecated OpenCode internal naming may vary across versions */
call_id?: string
}
export interface SyncSessionCreatedEvent {