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:
27
AGENTS.md
27
AGENTS.md
@@ -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
128
src/AGENTS.md
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
93
src/config/AGENTS.md
Normal 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"
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -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
|
||||
├── 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)
|
||||
├── tmux-subagent/ # Tmux session management (472 lines)
|
||||
├── mcp-oauth/ # MCP OAuth handling
|
||||
└── claude-tasks/ # Task schema/storage - see AGENTS.md
|
||||
```
|
||||
├── 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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from "./types"
|
||||
export * from "./storage"
|
||||
export * from "./session-storage"
|
||||
|
||||
204
src/features/claude-tasks/session-storage.test.ts
Normal file
204
src/features/claude-tasks/session-storage.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
52
src/features/claude-tasks/session-storage.ts
Normal file
52
src/features/claude-tasks/session-storage.ts
Normal 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
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
43
src/features/tmux-subagent/manager-cleanup.ts
Normal file
43
src/features/tmux-subagent/manager-cleanup.ts
Normal 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")
|
||||
}
|
||||
}
|
||||
139
src/features/tmux-subagent/polling-manager.ts
Normal file
139
src/features/tmux-subagent/polling-manager.ts
Normal 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) })
|
||||
}
|
||||
}
|
||||
}
|
||||
80
src/features/tmux-subagent/session-cleaner.ts
Normal file
80
src/features/tmux-subagent/session-cleaner.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
166
src/features/tmux-subagent/session-spawner.ts
Normal file
166
src/features/tmux-subagent/session-spawner.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
|
||||
125
src/hooks/interactive-bash-session/hook.ts
Normal file
125
src/hooks/interactive-bash-session/hook.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
118
src/hooks/interactive-bash-session/parser.ts
Normal file
118
src/hooks/interactive-bash-session/parser.ts
Normal 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 ""
|
||||
}
|
||||
40
src/hooks/interactive-bash-session/state-manager.ts
Normal file
40
src/hooks/interactive-bash-session/state-manager.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
96
src/plugin-handlers/AGENTS.md
Normal file
96
src/plugin-handlers/AGENTS.md
Normal 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()
|
||||
]);
|
||||
```
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
export {
|
||||
createBackgroundTask,
|
||||
createBackgroundOutput,
|
||||
createBackgroundCancel,
|
||||
} from "./tools"
|
||||
|
||||
export type * from "./types"
|
||||
export * from "./constants"
|
||||
export type { BackgroundOutputClient, BackgroundOutputManager, BackgroundCancelClient } from "./tools"
|
||||
|
||||
116
src/tools/background-task/modules/background-cancel.ts
Normal file
116
src/tools/background-task/modules/background-cancel.ts
Normal 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)}`
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
137
src/tools/background-task/modules/background-output.ts
Normal file
137
src/tools/background-task/modules/background-output.ts
Normal 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)}`
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
105
src/tools/background-task/modules/background-task.ts
Normal file
105
src/tools/background-task/modules/background-task.ts
Normal 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}`
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
311
src/tools/background-task/modules/formatters.ts
Normal file
311
src/tools/background-task/modules/formatters.ts
Normal 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")
|
||||
}
|
||||
75
src/tools/background-task/modules/message-processing.ts
Normal file
75
src/tools/background-task/modules/message-processing.ts
Normal 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 []
|
||||
}
|
||||
65
src/tools/background-task/modules/utils.ts
Normal file
65
src/tools/background-task/modules/utils.ts
Normal 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
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
83
src/tools/call-omo-agent/background-executor.ts
Normal file
83
src/tools/call-omo-agent/background-executor.ts
Normal 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}`
|
||||
}
|
||||
}
|
||||
67
src/tools/call-omo-agent/completion-poller.ts
Normal file
67
src/tools/call-omo-agent/completion-poller.ts
Normal 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.")
|
||||
}
|
||||
}
|
||||
18
src/tools/call-omo-agent/message-dir.ts
Normal file
18
src/tools/call-omo-agent/message-dir.ts
Normal 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
|
||||
}
|
||||
84
src/tools/call-omo-agent/message-processor.ts
Normal file
84
src/tools/call-omo-agent/message-processor.ts
Normal 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
|
||||
}
|
||||
70
src/tools/call-omo-agent/session-creator.ts
Normal file
70
src/tools/call-omo-agent/session-creator.ts
Normal 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 }
|
||||
}
|
||||
}
|
||||
59
src/tools/call-omo-agent/sync-executor.ts
Normal file
59
src/tools/call-omo-agent/sync-executor.ts
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
21
src/tools/delegate-task/skill-resolver.ts
Normal file
21
src/tools/delegate-task/skill-resolver.ts
Normal 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 }
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user