Implement unified Claude Tasks system with single multi-action tool (#1356)

* chore: pin bun-types to 1.3.6

🤖 Generated with [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)

* chore: exclude test files and script from tsconfig

🤖 Generated with [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)

* refactor: remove sisyphus-swarm feature

Remove mailbox types and swarm config schema. Update docs.

🤖 Generated with [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)

* refactor: remove legacy sisyphus-tasks feature

Remove old storage and types implementation, replaced by claude-tasks.

🤖 Generated with [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)

* feat(claude-tasks): add task schema and storage utilities

- Task schema with Zod validation (pending, in_progress, completed, deleted)
- Storage utilities: getTaskDir, readJsonSafe, writeJsonAtomic, acquireLock
- Atomic writes with temp file + rename
- File-based locking with 30s stale threshold

🤖 Generated with [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)

* feat(tools/task): add task object schemas

Add Zod schemas for task CRUD operations input validation.

🤖 Generated with [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)

* feat(tools): add TaskCreate tool

Create new tasks with sequential ID generation and lock-based concurrency.

🤖 Generated with [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)

* feat(tools): add TaskGet tool

Retrieve task by ID with null-safe handling.

🤖 Generated with [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)

* feat(tools): add TaskUpdate tool with claim validation

Update tasks with status transitions and owner claim validation.

🤖 Generated with [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)

* feat(tools): add TaskList tool and exports

- TaskList for summary view of all tasks
- Export all claude-tasks tool factories from index

🤖 Generated with [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)

* feat(hooks): add task-reminder hook

Remind agents to use task tools after 10 turns without task operations.

🤖 Generated with [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)

* feat(config): add disabled_tools setting and tasks-todowrite-disabler hook

- Add disabled_tools config option to disable specific tools by name
- Register tasks-todowrite-disabler hook name in schema

🤖 Generated with [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)

* feat(config-handler): add task_* and teammate tool permissions

Grant task_* and teammate permissions to atlas, sisyphus, prometheus, and sisyphus-junior agents.

🤖 Generated with [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)

* feat(delegate-task): add execute option for task execution

Add optional execute field with task_id and task_dir for task-based delegation.

🤖 Generated with [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)

* fix(truncator): add type guard for non-string outputs

Prevent crashes when output is not a string by adding typeof checks.

🤖 Generated with [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)

* chore: export config types and update task-resume-info

- Export SisyphusConfig and SisyphusTasksConfig types
- Add task_tool to TARGET_TOOLS list

🤖 Generated with [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)

* refactor(storage): remove team namespace, use flat task directory

* feat(task): implement unified task tool with all 5 actions

* fix(hooks): update task-reminder to track unified task tool

* refactor(tools): register unified task tool, remove 4 separate tools

* chore(cleanup): remove old 4-tool task implementation

* refactor(config): use new_task_system_enabled as top-level flag

- Add new_task_system_enabled to OhMyOpenCodeConfigSchema
- Remove enabled from SisyphusTasksConfigSchema (keep storage_path, claude_code_compat)
- Update index.ts to gate on new_task_system_enabled
- Update plugin-config.ts default for config initialization
- Update test configs in task.test.ts and storage.test.ts

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>

* fix: resolve typecheck and test failures

- Add explicit ToolDefinition return type to createTask function
- Fix planDemoteConfig to use 'subagent' mode instead of 'all'

---------

Co-authored-by: justsisyphus <justsisyphus@users.noreply.github.com>
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
YeonGyu-Kim
2026-02-01 22:42:28 +09:00
committed by GitHub
parent 491df05b63
commit 8d29a1c5c7
32 changed files with 2070 additions and 684 deletions

View File

@@ -97,6 +97,12 @@
] ]
} }
}, },
"disabled_tools": {
"type": "array",
"items": {
"type": "string"
}
},
"agents": { "agents": {
"type": "object", "type": "object",
"properties": { "properties": {

View File

@@ -1017,9 +1017,9 @@ Configure notification behavior for background task completion.
| -------------- | ------- | ---------------------------------------------------------------------------------------------- | | -------------- | ------- | ---------------------------------------------------------------------------------------------- |
| `force_enable` | `false` | Force enable session-notification even if external notification plugins are detected. Default: `false`. | | `force_enable` | `false` | Force enable session-notification even if external notification plugins are detected. Default: `false`. |
## Sisyphus Tasks & Swarm ## Sisyphus Tasks
Configure Sisyphus Tasks and Swarm systems for advanced task management and multi-agent orchestration. Configure Sisyphus Tasks system for advanced task management.
```json ```json
{ {
@@ -1028,11 +1028,6 @@ Configure Sisyphus Tasks and Swarm systems for advanced task management and mult
"enabled": false, "enabled": false,
"storage_path": ".sisyphus/tasks", "storage_path": ".sisyphus/tasks",
"claude_code_compat": false "claude_code_compat": false
},
"swarm": {
"enabled": false,
"storage_path": ".sisyphus/teams",
"ui_mode": "toast"
} }
} }
} }
@@ -1046,14 +1041,6 @@ Configure Sisyphus Tasks and Swarm systems for advanced task management and mult
| `storage_path` | `.sisyphus/tasks` | Storage path for tasks (relative to project root) | | `storage_path` | `.sisyphus/tasks` | Storage path for tasks (relative to project root) |
| `claude_code_compat` | `false` | Enable Claude Code path compatibility mode | | `claude_code_compat` | `false` | Enable Claude Code path compatibility mode |
### Swarm Configuration
| Option | Default | Description |
| -------------- | ------------------ | -------------------------------------------------------------- |
| `enabled` | `false` | Enable Sisyphus Swarm system for multi-agent orchestration |
| `storage_path` | `.sisyphus/teams` | Storage path for teams (relative to project root) |
| `ui_mode` | `toast` | UI mode: `toast` (notifications), `tmux` (panes), or `both` |
## MCPs ## MCPs
Exa, Context7 and grep.app MCP enabled by default. Exa, Context7 and grep.app MCP enabled by default.

View File

@@ -70,7 +70,7 @@
"devDependencies": { "devDependencies": {
"@types/js-yaml": "^4.0.9", "@types/js-yaml": "^4.0.9",
"@types/picomatch": "^3.0.2", "@types/picomatch": "^3.0.2",
"bun-types": "latest", "bun-types": "1.3.6",
"typescript": "^5.7.3" "typescript": "^5.7.3"
}, },
"optionalDependencies": { "optionalDependencies": {

View File

@@ -27,4 +27,6 @@ export type {
RalphLoopConfig, RalphLoopConfig,
TmuxConfig, TmuxConfig,
TmuxLayout, TmuxLayout,
SisyphusConfig,
SisyphusTasksConfig,
} from "./schema" } from "./schema"

View File

@@ -92,6 +92,7 @@ export const HookNameSchema = z.enum([
"atlas", "atlas",
"unstable-agent-babysitter", "unstable-agent-babysitter",
"stop-continuation-guard", "stop-continuation-guard",
"tasks-todowrite-disabler",
]) ])
export const BuiltinCommandNameSchema = z.enum([ export const BuiltinCommandNameSchema = z.enum([
@@ -352,34 +353,26 @@ export const TmuxConfigSchema = z.object({
}) })
export const SisyphusTasksConfigSchema = z.object({ export const SisyphusTasksConfigSchema = z.object({
/** Enable Sisyphus Tasks system (default: false) */
enabled: z.boolean().default(false),
/** Storage path for tasks (default: .sisyphus/tasks) */ /** Storage path for tasks (default: .sisyphus/tasks) */
storage_path: z.string().default(".sisyphus/tasks"), storage_path: z.string().default(".sisyphus/tasks"),
/** Enable Claude Code path compatibility mode */ /** Enable Claude Code path compatibility mode */
claude_code_compat: z.boolean().default(false), claude_code_compat: z.boolean().default(false),
}) })
export const SisyphusSwarmConfigSchema = z.object({
/** Enable Sisyphus Swarm system (default: false) */
enabled: z.boolean().default(false),
/** Storage path for teams (default: .sisyphus/teams) */
storage_path: z.string().default(".sisyphus/teams"),
/** UI mode: toast notifications, tmux panes, or both */
ui_mode: z.enum(["toast", "tmux", "both"]).default("toast"),
})
export const SisyphusConfigSchema = z.object({ export const SisyphusConfigSchema = z.object({
tasks: SisyphusTasksConfigSchema.optional(), tasks: SisyphusTasksConfigSchema.optional(),
swarm: SisyphusSwarmConfigSchema.optional(),
}) })
export const OhMyOpenCodeConfigSchema = z.object({ export const OhMyOpenCodeConfigSchema = z.object({
$schema: z.string().optional(), $schema: z.string().optional(),
/** Enable new task system (default: false) */
new_task_system_enabled: z.boolean().default(false),
disabled_mcps: z.array(AnyMcpNameSchema).optional(), disabled_mcps: z.array(AnyMcpNameSchema).optional(),
disabled_agents: z.array(BuiltinAgentNameSchema).optional(), disabled_agents: z.array(BuiltinAgentNameSchema).optional(),
disabled_skills: z.array(BuiltinSkillNameSchema).optional(), disabled_skills: z.array(BuiltinSkillNameSchema).optional(),
disabled_hooks: z.array(HookNameSchema).optional(), disabled_hooks: z.array(HookNameSchema).optional(),
disabled_commands: z.array(BuiltinCommandNameSchema).optional(), disabled_commands: z.array(BuiltinCommandNameSchema).optional(),
/** Disable specific tools by name (e.g., ["todowrite", "todoread"]) */
disabled_tools: z.array(z.string()).optional(),
agents: AgentOverridesSchema.optional(), agents: AgentOverridesSchema.optional(),
categories: CategoriesConfigSchema.optional(), categories: CategoriesConfigSchema.optional(),
claude_code: ClaudeCodeConfigSchema.optional(), claude_code: ClaudeCodeConfigSchema.optional(),
@@ -424,7 +417,6 @@ export type BrowserAutomationConfig = z.infer<typeof BrowserAutomationConfigSche
export type TmuxConfig = z.infer<typeof TmuxConfigSchema> export type TmuxConfig = z.infer<typeof TmuxConfigSchema>
export type TmuxLayout = z.infer<typeof TmuxLayoutSchema> export type TmuxLayout = z.infer<typeof TmuxLayoutSchema>
export type SisyphusTasksConfig = z.infer<typeof SisyphusTasksConfigSchema> export type SisyphusTasksConfig = z.infer<typeof SisyphusTasksConfigSchema>
export type SisyphusSwarmConfig = z.infer<typeof SisyphusSwarmConfigSchema>
export type SisyphusConfig = z.infer<typeof SisyphusConfigSchema> export type SisyphusConfig = z.infer<typeof SisyphusConfigSchema>
export { AnyMcpNameSchema, type AnyMcpName, McpNameSchema, type McpName } from "../mcp/types" export { AnyMcpNameSchema, type AnyMcpName, McpNameSchema, type McpName } from "../mcp/types"

View File

@@ -0,0 +1,102 @@
# CLAUDE TASKS FEATURE KNOWLEDGE BASE
## OVERVIEW
Claude Code compatible task schema and storage. Provides core task management utilities used by task-related tools and features.
## STRUCTURE
```
claude-tasks/
├── types.ts # Task schema (Zod)
├── types.test.ts # Schema validation tests (8 tests)
├── storage.ts # File operations
├── storage.test.ts # Storage tests (14 tests)
└── index.ts # Barrel exports
```
## TASK SCHEMA
```typescript
type TaskStatus = "pending" | "in_progress" | "completed" | "deleted"
interface Task {
id: string
subject: string // Imperative: "Run tests"
description: string
status: TaskStatus
activeForm?: string // Present continuous: "Running tests"
blocks: string[] // Task IDs this task blocks
blockedBy: string[] // Task IDs blocking this task
owner?: string // Agent name
metadata?: Record<string, unknown>
}
```
**Key Differences from Legacy**:
- `subject` (was `title`)
- `blockedBy` (was `dependsOn`)
- No `parentID`, `repoURL`, `threadID` fields
## STORAGE UTILITIES
### getTaskDir(teamName, config)
Returns: `.sisyphus/tasks/{teamName}` (or custom path from config)
### readJsonSafe(filePath, schema)
- Returns parsed & validated data or `null`
- Safe for missing files, invalid JSON, schema violations
### writeJsonAtomic(filePath, data)
- Atomic write via temp file + rename
- Creates parent directories automatically
- Cleans up temp file on error
### acquireLock(dirPath)
- File-based lock: `.lock` file with timestamp
- 30-second stale threshold
- Returns `{ acquired: boolean, release: () => void }`
## TESTING
**types.test.ts** (8 tests):
- Valid status enum values
- Required vs optional fields
- Array validation (blocks, blockedBy)
- Schema rejection for invalid data
**storage.test.ts** (14 tests):
- Path construction
- Safe JSON reading (missing files, invalid JSON, schema failures)
- Atomic writes (directory creation, overwrites)
- Lock acquisition (fresh locks, stale locks, release)
## USAGE
```typescript
import { TaskSchema, getTaskDir, readJsonSafe, writeJsonAtomic, acquireLock } from "./features/claude-tasks"
const taskDir = getTaskDir("my-team", config)
const lock = acquireLock(taskDir)
try {
const task = readJsonSafe(join(taskDir, "1.json"), TaskSchema)
if (task) {
task.status = "completed"
writeJsonAtomic(join(taskDir, "1.json"), task)
}
} finally {
lock.release()
}
```
## ANTI-PATTERNS
- Direct fs operations (use storage utilities)
- Skipping lock acquisition for writes
- Ignoring null returns from readJsonSafe
- Using old schema field names (title, dependsOn)

View File

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

View File

@@ -0,0 +1,361 @@
import { describe, test, expect, beforeEach, afterEach } from "bun:test"
import { existsSync, mkdirSync, rmSync, writeFileSync } from "fs"
import { join } from "path"
import { z } from "zod"
import { getTaskDir, readJsonSafe, writeJsonAtomic, acquireLock, generateTaskId, listTaskFiles } from "./storage"
import type { OhMyOpenCodeConfig } from "../../config/schema"
const TEST_DIR = ".test-claude-tasks"
const TEST_DIR_ABS = join(process.cwd(), TEST_DIR)
describe("getTaskDir", () => {
test("returns correct path for default config", () => {
//#given
const config: Partial<OhMyOpenCodeConfig> = {}
//#when
const result = getTaskDir(config)
//#then
expect(result).toBe(join(process.cwd(), ".sisyphus/tasks"))
})
test("returns correct path with custom storage_path", () => {
//#given
const config: Partial<OhMyOpenCodeConfig> = {
sisyphus: {
tasks: {
storage_path: ".custom/tasks",
claude_code_compat: false,
},
},
}
//#when
const result = getTaskDir(config)
//#then
expect(result).toBe(join(process.cwd(), ".custom/tasks"))
})
test("returns correct path with default config parameter", () => {
//#when
const result = getTaskDir()
//#then
expect(result).toBe(join(process.cwd(), ".sisyphus/tasks"))
})
})
describe("generateTaskId", () => {
test("generates task ID with T- prefix and UUID", () => {
//#when
const taskId = generateTaskId()
//#then
expect(taskId).toMatch(/^T-[a-f0-9-]{36}$/)
})
test("generates unique task IDs", () => {
//#when
const id1 = generateTaskId()
const id2 = generateTaskId()
//#then
expect(id1).not.toBe(id2)
})
})
describe("listTaskFiles", () => {
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 for non-existent directory", () => {
//#given
const config: Partial<OhMyOpenCodeConfig> = {
new_task_system_enabled: false,
sisyphus: { tasks: { storage_path: TEST_DIR, claude_code_compat: false } }
}
//#when
const result = listTaskFiles(config)
//#then
expect(result).toEqual([])
})
test("returns empty array for directory with no task files", () => {
//#given
const config: Partial<OhMyOpenCodeConfig> = {
new_task_system_enabled: false,
sisyphus: { tasks: { storage_path: TEST_DIR, claude_code_compat: false } }
}
mkdirSync(TEST_DIR_ABS, { recursive: true })
writeFileSync(join(TEST_DIR_ABS, "other.json"), "{}", "utf-8")
//#when
const result = listTaskFiles(config)
//#then
expect(result).toEqual([])
})
test("lists task files with T- prefix and .json extension", () => {
//#given
const config: Partial<OhMyOpenCodeConfig> = {
new_task_system_enabled: false,
sisyphus: { tasks: { storage_path: TEST_DIR, claude_code_compat: false } }
}
mkdirSync(TEST_DIR_ABS, { recursive: true })
writeFileSync(join(TEST_DIR_ABS, "T-abc123.json"), "{}", "utf-8")
writeFileSync(join(TEST_DIR_ABS, "T-def456.json"), "{}", "utf-8")
writeFileSync(join(TEST_DIR_ABS, "other.json"), "{}", "utf-8")
writeFileSync(join(TEST_DIR_ABS, "notes.md"), "# notes", "utf-8")
//#when
const result = listTaskFiles(config)
//#then
expect(result).toHaveLength(2)
expect(result).toContain("T-abc123")
expect(result).toContain("T-def456")
})
test("returns task IDs without .json extension", () => {
//#given
const config: Partial<OhMyOpenCodeConfig> = {
new_task_system_enabled: false,
sisyphus: { tasks: { storage_path: TEST_DIR, claude_code_compat: false } }
}
mkdirSync(TEST_DIR_ABS, { recursive: true })
writeFileSync(join(TEST_DIR_ABS, "T-test-id.json"), "{}", "utf-8")
//#when
const result = listTaskFiles(config)
//#then
expect(result[0]).toBe("T-test-id")
expect(result[0]).not.toContain(".json")
})
})
describe("readJsonSafe", () => {
const testSchema = z.object({
id: z.string(),
value: z.number(),
})
beforeEach(() => {
if (existsSync(TEST_DIR_ABS)) {
rmSync(TEST_DIR_ABS, { recursive: true, force: true })
}
mkdirSync(TEST_DIR_ABS, { recursive: true })
})
afterEach(() => {
if (existsSync(TEST_DIR_ABS)) {
rmSync(TEST_DIR_ABS, { recursive: true, force: true })
}
})
test("returns null for non-existent file", () => {
//#given
const filePath = join(TEST_DIR_ABS, "nonexistent.json")
//#when
const result = readJsonSafe(filePath, testSchema)
//#then
expect(result).toBeNull()
})
test("returns parsed data for valid file", () => {
//#given
const filePath = join(TEST_DIR_ABS, "valid.json")
const data = { id: "test", value: 42 }
writeFileSync(filePath, JSON.stringify(data), "utf-8")
//#when
const result = readJsonSafe(filePath, testSchema)
//#then
expect(result).toEqual(data)
})
test("returns null for invalid JSON", () => {
//#given
const filePath = join(TEST_DIR_ABS, "invalid.json")
writeFileSync(filePath, "{ invalid json", "utf-8")
//#when
const result = readJsonSafe(filePath, testSchema)
//#then
expect(result).toBeNull()
})
test("returns null for data that fails schema validation", () => {
//#given
const filePath = join(TEST_DIR_ABS, "invalid-schema.json")
const data = { id: "test", value: "not-a-number" }
writeFileSync(filePath, JSON.stringify(data), "utf-8")
//#when
const result = readJsonSafe(filePath, testSchema)
//#then
expect(result).toBeNull()
})
})
describe("writeJsonAtomic", () => {
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("creates directory if it does not exist", () => {
//#given
const filePath = join(TEST_DIR_ABS, "nested", "dir", "file.json")
const data = { test: "data" }
//#when
writeJsonAtomic(filePath, data)
//#then
expect(existsSync(filePath)).toBe(true)
})
test("writes data atomically", async () => {
//#given
const filePath = join(TEST_DIR_ABS, "atomic.json")
const data = { id: "test", value: 123 }
//#when
writeJsonAtomic(filePath, data)
//#then
expect(existsSync(filePath)).toBe(true)
const content = await Bun.file(filePath).text()
expect(JSON.parse(content)).toEqual(data)
})
test("overwrites existing file", async () => {
//#given
const filePath = join(TEST_DIR_ABS, "overwrite.json")
mkdirSync(TEST_DIR_ABS, { recursive: true })
writeFileSync(filePath, JSON.stringify({ old: "data" }), "utf-8")
//#when
const newData = { new: "data" }
writeJsonAtomic(filePath, newData)
//#then
const content = await Bun.file(filePath).text()
expect(JSON.parse(content)).toEqual(newData)
})
})
describe("acquireLock", () => {
beforeEach(() => {
if (existsSync(TEST_DIR_ABS)) {
rmSync(TEST_DIR_ABS, { recursive: true, force: true })
}
mkdirSync(TEST_DIR_ABS, { recursive: true })
})
afterEach(() => {
if (existsSync(TEST_DIR_ABS)) {
rmSync(TEST_DIR_ABS, { recursive: true, force: true })
}
})
test("acquires lock when no lock exists", () => {
//#given
const dirPath = TEST_DIR_ABS
//#when
const lock = acquireLock(dirPath)
//#then
expect(lock.acquired).toBe(true)
expect(existsSync(join(dirPath, ".lock"))).toBe(true)
//#cleanup
lock.release()
})
test("fails to acquire lock when fresh lock exists", () => {
//#given
const dirPath = TEST_DIR
const firstLock = acquireLock(dirPath)
//#when
const secondLock = acquireLock(dirPath)
//#then
expect(secondLock.acquired).toBe(false)
//#cleanup
firstLock.release()
})
test("acquires lock when stale lock exists (>30s)", () => {
//#given
const dirPath = TEST_DIR
const lockPath = join(dirPath, ".lock")
const staleTimestamp = Date.now() - 31000 // 31 seconds ago
writeFileSync(lockPath, JSON.stringify({ timestamp: staleTimestamp }), "utf-8")
//#when
const lock = acquireLock(dirPath)
//#then
expect(lock.acquired).toBe(true)
//#cleanup
lock.release()
})
test("release removes lock file", () => {
//#given
const dirPath = TEST_DIR
const lock = acquireLock(dirPath)
const lockPath = join(dirPath, ".lock")
//#when
lock.release()
//#then
expect(existsSync(lockPath)).toBe(false)
})
test("release is safe to call multiple times", () => {
//#given
const dirPath = TEST_DIR
const lock = acquireLock(dirPath)
//#when
lock.release()
lock.release()
//#then
expect(existsSync(join(dirPath, ".lock"))).toBe(false)
})
})

View File

@@ -0,0 +1,112 @@
import { join, dirname } from "path"
import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, unlinkSync, readdirSync } from "fs"
import { randomUUID } from "crypto"
import type { z } from "zod"
import type { OhMyOpenCodeConfig } from "../../config/schema"
export function getTaskDir(config: Partial<OhMyOpenCodeConfig> = {}): string {
const tasksConfig = config.sisyphus?.tasks
const storagePath = tasksConfig?.storage_path ?? ".sisyphus/tasks"
return join(process.cwd(), storagePath)
}
export function ensureDir(dirPath: string): void {
if (!existsSync(dirPath)) {
mkdirSync(dirPath, { recursive: true })
}
}
export function readJsonSafe<T>(filePath: string, schema: z.ZodType<T>): T | null {
try {
if (!existsSync(filePath)) {
return null
}
const content = readFileSync(filePath, "utf-8")
const parsed = JSON.parse(content)
const result = schema.safeParse(parsed)
if (!result.success) {
return null
}
return result.data
} catch {
return null
}
}
export function writeJsonAtomic(filePath: string, data: unknown): void {
const dir = dirname(filePath)
ensureDir(dir)
const tempPath = `${filePath}.tmp.${Date.now()}`
try {
writeFileSync(tempPath, JSON.stringify(data, null, 2), "utf-8")
renameSync(tempPath, filePath)
} catch (error) {
try {
if (existsSync(tempPath)) {
unlinkSync(tempPath)
}
} catch {
// Ignore cleanup errors
}
throw error
}
}
const STALE_LOCK_THRESHOLD_MS = 30000
export function generateTaskId(): string {
return `T-${randomUUID()}`
}
export function listTaskFiles(config: Partial<OhMyOpenCodeConfig> = {}): string[] {
const dir = getTaskDir(config)
if (!existsSync(dir)) return []
return readdirSync(dir)
.filter((f) => f.endsWith('.json') && f.startsWith('T-'))
.map((f) => f.replace('.json', ''))
}
export function acquireLock(dirPath: string): { acquired: boolean; release: () => void } {
const lockPath = join(dirPath, ".lock")
const now = Date.now()
if (existsSync(lockPath)) {
try {
const lockContent = readFileSync(lockPath, "utf-8")
const lockData = JSON.parse(lockContent)
const lockAge = now - lockData.timestamp
if (lockAge <= STALE_LOCK_THRESHOLD_MS) {
return {
acquired: false,
release: () => {
// No-op release for failed acquisition
},
}
}
} catch {
// If lock file is corrupted, treat as stale and override
}
}
ensureDir(dirPath)
writeFileSync(lockPath, JSON.stringify({ timestamp: now }), "utf-8")
return {
acquired: true,
release: () => {
try {
if (existsSync(lockPath)) {
unlinkSync(lockPath)
}
} catch {
// Ignore cleanup errors
}
},
}
}

View File

@@ -0,0 +1,174 @@
import { describe, test, expect } from "bun:test"
import { TaskSchema, TaskStatusSchema, type Task, type TaskStatus } from "./types"
describe("TaskStatusSchema", () => {
test("accepts valid status values", () => {
//#given
const validStatuses: TaskStatus[] = ["pending", "in_progress", "completed", "deleted"]
//#when
const results = validStatuses.map((status) => TaskStatusSchema.safeParse(status))
//#then
results.forEach((result) => {
expect(result.success).toBe(true)
})
})
test("rejects invalid status values", () => {
//#given
const invalidStatuses = ["open", "closed", "archived", ""]
//#when
const results = invalidStatuses.map((status) => TaskStatusSchema.safeParse(status))
//#then
results.forEach((result) => {
expect(result.success).toBe(false)
})
})
})
describe("TaskSchema", () => {
test("parses valid Task with all required fields", () => {
//#given
const validTask = {
id: "1",
subject: "Run tests",
description: "Execute test suite",
status: "pending" as TaskStatus,
blocks: [],
blockedBy: [],
}
//#when
const result = TaskSchema.safeParse(validTask)
//#then
expect(result.success).toBe(true)
if (result.success) {
expect(result.data.id).toBe("1")
expect(result.data.subject).toBe("Run tests")
expect(result.data.status).toBe("pending")
expect(result.data.blocks).toEqual([])
expect(result.data.blockedBy).toEqual([])
}
})
test("parses Task with optional fields", () => {
//#given
const taskWithOptionals: Task = {
id: "2",
subject: "Deploy app",
description: "Deploy to production",
status: "in_progress",
activeForm: "Deploying app",
blocks: ["3", "4"],
blockedBy: ["1"],
owner: "sisyphus",
metadata: { priority: "high", tags: ["urgent"] },
}
//#when
const result = TaskSchema.safeParse(taskWithOptionals)
//#then
expect(result.success).toBe(true)
if (result.success) {
expect(result.data.activeForm).toBe("Deploying app")
expect(result.data.owner).toBe("sisyphus")
expect(result.data.metadata).toEqual({ priority: "high", tags: ["urgent"] })
}
})
test("validates blocks and blockedBy as arrays", () => {
//#given
const taskWithDeps = {
id: "3",
subject: "Test feature",
description: "Test new feature",
status: "pending" as TaskStatus,
blocks: ["4", "5", "6"],
blockedBy: ["1", "2"],
}
//#when
const result = TaskSchema.safeParse(taskWithDeps)
//#then
expect(result.success).toBe(true)
if (result.success) {
expect(Array.isArray(result.data.blocks)).toBe(true)
expect(result.data.blocks).toHaveLength(3)
expect(Array.isArray(result.data.blockedBy)).toBe(true)
expect(result.data.blockedBy).toHaveLength(2)
}
})
test("rejects Task missing required fields", () => {
//#given
const invalidTasks = [
{ subject: "No ID", description: "Missing id", status: "pending", blocks: [], blockedBy: [] },
{ id: "1", description: "No subject", status: "pending", blocks: [], blockedBy: [] },
{ id: "1", subject: "No description", status: "pending", blocks: [], blockedBy: [] },
{ id: "1", subject: "No status", description: "Missing status", blocks: [], blockedBy: [] },
{ id: "1", subject: "No blocks", description: "Missing blocks", status: "pending", blockedBy: [] },
{ id: "1", subject: "No blockedBy", description: "Missing blockedBy", status: "pending", blocks: [] },
]
//#when
const results = invalidTasks.map((task) => TaskSchema.safeParse(task))
//#then
results.forEach((result) => {
expect(result.success).toBe(false)
})
})
test("rejects Task with invalid status", () => {
//#given
const taskWithInvalidStatus = {
id: "1",
subject: "Test",
description: "Test task",
status: "invalid_status",
blocks: [],
blockedBy: [],
}
//#when
const result = TaskSchema.safeParse(taskWithInvalidStatus)
//#then
expect(result.success).toBe(false)
})
test("rejects Task with non-array blocks or blockedBy", () => {
//#given
const taskWithInvalidBlocks = {
id: "1",
subject: "Test",
description: "Test task",
status: "pending",
blocks: "not-an-array",
blockedBy: [],
}
const taskWithInvalidBlockedBy = {
id: "1",
subject: "Test",
description: "Test task",
status: "pending",
blocks: [],
blockedBy: "not-an-array",
}
//#when
const result1 = TaskSchema.safeParse(taskWithInvalidBlocks)
const result2 = TaskSchema.safeParse(taskWithInvalidBlockedBy)
//#then
expect(result1.success).toBe(false)
expect(result2.success).toBe(false)
})
})

View File

@@ -0,0 +1,20 @@
import { z } from "zod"
export const TaskStatusSchema = z.enum(["pending", "in_progress", "completed", "deleted"])
export type TaskStatus = z.infer<typeof TaskStatusSchema>
export const TaskSchema = z
.object({
id: z.string(),
subject: z.string(),
description: z.string(),
status: TaskStatusSchema,
activeForm: z.string().optional(),
blocks: z.array(z.string()),
blockedBy: z.array(z.string()),
owner: z.string().optional(),
metadata: z.record(z.string(), z.unknown()).optional(),
})
.strict()
export type Task = z.infer<typeof TaskSchema>

View File

@@ -1,112 +0,0 @@
import { describe, it, expect } from "bun:test"
import {
MailboxMessageSchema,
PermissionRequestSchema,
PermissionResponseSchema,
ShutdownRequestSchema,
TaskAssignmentSchema,
JoinRequestSchema,
ProtocolMessageSchema,
} from "./types"
describe("MailboxMessageSchema", () => {
// given a valid mailbox message
// when parsing
// then it should succeed
it("parses valid message", () => {
const msg = {
from: "agent-001",
text: '{"type":"idle_notification"}',
timestamp: "2026-01-27T10:00:00Z",
read: false,
}
expect(MailboxMessageSchema.safeParse(msg).success).toBe(true)
})
// given a message with optional color
// when parsing
// then it should succeed
it("parses message with color", () => {
const msg = {
from: "agent-001",
text: "{}",
timestamp: "2026-01-27T10:00:00Z",
color: "blue",
read: true,
}
expect(MailboxMessageSchema.safeParse(msg).success).toBe(true)
})
})
describe("ProtocolMessageSchema", () => {
// given permission_request message
// when parsing
// then it should succeed
it("parses permission_request", () => {
const msg = {
type: "permission_request",
requestId: "req-123",
toolName: "Bash",
input: { command: "rm -rf /" },
agentId: "agent-001",
timestamp: Date.now(),
}
expect(PermissionRequestSchema.safeParse(msg).success).toBe(true)
})
// given permission_response message
// when parsing
// then it should succeed
it("parses permission_response", () => {
const approved = {
type: "permission_response",
requestId: "req-123",
decision: "approved",
updatedInput: { command: "ls" },
}
expect(PermissionResponseSchema.safeParse(approved).success).toBe(true)
const rejected = {
type: "permission_response",
requestId: "req-123",
decision: "rejected",
feedback: "Too dangerous",
}
expect(PermissionResponseSchema.safeParse(rejected).success).toBe(true)
})
// given shutdown_request message
// when parsing
// then it should succeed
it("parses shutdown messages", () => {
const request = { type: "shutdown_request" }
expect(ShutdownRequestSchema.safeParse(request).success).toBe(true)
})
// given task_assignment message
// when parsing
// then it should succeed
it("parses task_assignment", () => {
const msg = {
type: "task_assignment",
taskId: "1",
subject: "Fix bug",
description: "Fix the auth bug",
assignedBy: "team-lead",
timestamp: Date.now(),
}
expect(TaskAssignmentSchema.safeParse(msg).success).toBe(true)
})
// given join_request message
// when parsing
// then it should succeed
it("parses join_request", () => {
const msg = {
type: "join_request",
agentName: "new-agent",
sessionId: "sess-123",
}
expect(JoinRequestSchema.safeParse(msg).success).toBe(true)
})
})

View File

@@ -1,153 +0,0 @@
import { z } from "zod"
export const MailboxMessageSchema = z.object({
from: z.string(),
text: z.string(),
timestamp: z.string(),
color: z.string().optional(),
read: z.boolean(),
})
export type MailboxMessage = z.infer<typeof MailboxMessageSchema>
export const PermissionRequestSchema = z.object({
type: z.literal("permission_request"),
requestId: z.string(),
toolName: z.string(),
input: z.unknown(),
agentId: z.string(),
timestamp: z.number(),
})
export type PermissionRequest = z.infer<typeof PermissionRequestSchema>
export const PermissionResponseSchema = z.object({
type: z.literal("permission_response"),
requestId: z.string(),
decision: z.enum(["approved", "rejected"]),
updatedInput: z.unknown().optional(),
feedback: z.string().optional(),
permissionUpdates: z.unknown().optional(),
})
export type PermissionResponse = z.infer<typeof PermissionResponseSchema>
export const ShutdownRequestSchema = z.object({
type: z.literal("shutdown_request"),
})
export type ShutdownRequest = z.infer<typeof ShutdownRequestSchema>
export const ShutdownApprovedSchema = z.object({
type: z.literal("shutdown_approved"),
})
export type ShutdownApproved = z.infer<typeof ShutdownApprovedSchema>
export const ShutdownRejectedSchema = z.object({
type: z.literal("shutdown_rejected"),
reason: z.string().optional(),
})
export type ShutdownRejected = z.infer<typeof ShutdownRejectedSchema>
export const TaskAssignmentSchema = z.object({
type: z.literal("task_assignment"),
taskId: z.string(),
subject: z.string(),
description: z.string(),
assignedBy: z.string(),
timestamp: z.number(),
})
export type TaskAssignment = z.infer<typeof TaskAssignmentSchema>
export const TaskCompletedSchema = z.object({
type: z.literal("task_completed"),
taskId: z.string(),
agentId: z.string(),
timestamp: z.number(),
})
export type TaskCompleted = z.infer<typeof TaskCompletedSchema>
export const IdleNotificationSchema = z.object({
type: z.literal("idle_notification"),
})
export type IdleNotification = z.infer<typeof IdleNotificationSchema>
export const JoinRequestSchema = z.object({
type: z.literal("join_request"),
agentName: z.string(),
sessionId: z.string(),
})
export type JoinRequest = z.infer<typeof JoinRequestSchema>
export const JoinApprovedSchema = z.object({
type: z.literal("join_approved"),
agentName: z.string(),
teamName: z.string(),
})
export type JoinApproved = z.infer<typeof JoinApprovedSchema>
export const JoinRejectedSchema = z.object({
type: z.literal("join_rejected"),
reason: z.string().optional(),
})
export type JoinRejected = z.infer<typeof JoinRejectedSchema>
export const PlanApprovalRequestSchema = z.object({
type: z.literal("plan_approval_request"),
requestId: z.string(),
plan: z.string(),
agentId: z.string(),
})
export type PlanApprovalRequest = z.infer<typeof PlanApprovalRequestSchema>
export const PlanApprovalResponseSchema = z.object({
type: z.literal("plan_approval_response"),
requestId: z.string(),
decision: z.enum(["approved", "rejected"]),
feedback: z.string().optional(),
})
export type PlanApprovalResponse = z.infer<typeof PlanApprovalResponseSchema>
export const ModeSetRequestSchema = z.object({
type: z.literal("mode_set_request"),
mode: z.enum(["acceptEdits", "bypassPermissions", "default", "delegate", "dontAsk", "plan"]),
})
export type ModeSetRequest = z.infer<typeof ModeSetRequestSchema>
export const TeamPermissionUpdateSchema = z.object({
type: z.literal("team_permission_update"),
permissions: z.record(z.string(), z.unknown()),
})
export type TeamPermissionUpdate = z.infer<typeof TeamPermissionUpdateSchema>
export const ProtocolMessageSchema = z.discriminatedUnion("type", [
PermissionRequestSchema,
PermissionResponseSchema,
ShutdownRequestSchema,
ShutdownApprovedSchema,
ShutdownRejectedSchema,
TaskAssignmentSchema,
TaskCompletedSchema,
IdleNotificationSchema,
JoinRequestSchema,
JoinApprovedSchema,
JoinRejectedSchema,
PlanApprovalRequestSchema,
PlanApprovalResponseSchema,
ModeSetRequestSchema,
TeamPermissionUpdateSchema,
])
export type ProtocolMessage = z.infer<typeof ProtocolMessageSchema>

View File

@@ -1,178 +0,0 @@
import { describe, it, expect, beforeEach, afterEach } from "bun:test"
import { join } from "path"
import { mkdirSync, rmSync, existsSync, writeFileSync, readFileSync } from "fs"
import { z } from "zod"
import {
getTaskDir,
getTaskPath,
getTeamDir,
getInboxPath,
ensureDir,
readJsonSafe,
writeJsonAtomic,
} from "./storage"
const TEST_DIR = join(import.meta.dirname, ".test-storage")
describe("Storage Utilities", () => {
beforeEach(() => {
rmSync(TEST_DIR, { recursive: true, force: true })
mkdirSync(TEST_DIR, { recursive: true })
})
afterEach(() => {
rmSync(TEST_DIR, { recursive: true, force: true })
})
describe("getTaskDir", () => {
// given default config (no claude_code_compat)
// when getting task directory
// then it should return .sisyphus/tasks/{listId}
it("returns sisyphus path by default", () => {
const config = { sisyphus: { tasks: { storage_path: ".sisyphus/tasks" } } }
const result = getTaskDir("list-123", config as any)
expect(result).toContain(".sisyphus/tasks/list-123")
})
// given claude_code_compat enabled
// when getting task directory
// then it should return Claude Code path
it("returns claude code path when compat enabled", () => {
const config = {
sisyphus: {
tasks: {
storage_path: ".sisyphus/tasks",
claude_code_compat: true,
},
},
}
const result = getTaskDir("list-123", config as any)
expect(result).toContain(".cache/claude-code/tasks/list-123")
})
})
describe("getTaskPath", () => {
// given list and task IDs
// when getting task path
// then it should return path to task JSON file
it("returns path to task JSON", () => {
const config = { sisyphus: { tasks: { storage_path: ".sisyphus/tasks" } } }
const result = getTaskPath("list-123", "1", config as any)
expect(result).toContain("list-123/1.json")
})
})
describe("getTeamDir", () => {
// given team name and default config
// when getting team directory
// then it should return .sisyphus/teams/{teamName}
it("returns sisyphus team path", () => {
const config = { sisyphus: { swarm: { storage_path: ".sisyphus/teams" } } }
const result = getTeamDir("my-team", config as any)
expect(result).toContain(".sisyphus/teams/my-team")
})
})
describe("getInboxPath", () => {
// given team and agent names
// when getting inbox path
// then it should return path to inbox JSON file
it("returns path to inbox JSON", () => {
const config = { sisyphus: { swarm: { storage_path: ".sisyphus/teams" } } }
const result = getInboxPath("my-team", "agent-001", config as any)
expect(result).toContain("my-team/inboxes/agent-001.json")
})
})
describe("ensureDir", () => {
// given a non-existent directory path
// when calling ensureDir
// then it should create the directory
it("creates directory if not exists", () => {
const dirPath = join(TEST_DIR, "new-dir", "nested")
ensureDir(dirPath)
expect(existsSync(dirPath)).toBe(true)
})
// given an existing directory
// when calling ensureDir
// then it should not throw
it("does not throw for existing directory", () => {
const dirPath = join(TEST_DIR, "existing")
mkdirSync(dirPath, { recursive: true })
expect(() => ensureDir(dirPath)).not.toThrow()
})
})
describe("readJsonSafe", () => {
// given a valid JSON file matching schema
// when reading with readJsonSafe
// then it should return parsed object
it("reads and parses valid JSON", () => {
const testSchema = z.object({ name: z.string(), value: z.number() })
const filePath = join(TEST_DIR, "test.json")
writeFileSync(filePath, JSON.stringify({ name: "test", value: 42 }))
const result = readJsonSafe(filePath, testSchema)
expect(result).toEqual({ name: "test", value: 42 })
})
// given a non-existent file
// when reading with readJsonSafe
// then it should return null
it("returns null for non-existent file", () => {
const testSchema = z.object({ name: z.string() })
const result = readJsonSafe(join(TEST_DIR, "missing.json"), testSchema)
expect(result).toBeNull()
})
// given invalid JSON content
// when reading with readJsonSafe
// then it should return null
it("returns null for invalid JSON", () => {
const testSchema = z.object({ name: z.string() })
const filePath = join(TEST_DIR, "invalid.json")
writeFileSync(filePath, "not valid json")
const result = readJsonSafe(filePath, testSchema)
expect(result).toBeNull()
})
// given JSON that doesn't match schema
// when reading with readJsonSafe
// then it should return null
it("returns null for schema mismatch", () => {
const testSchema = z.object({ name: z.string(), required: z.number() })
const filePath = join(TEST_DIR, "mismatch.json")
writeFileSync(filePath, JSON.stringify({ name: "test" }))
const result = readJsonSafe(filePath, testSchema)
expect(result).toBeNull()
})
})
describe("writeJsonAtomic", () => {
// given data to write
// when calling writeJsonAtomic
// then it should write to file atomically
it("writes JSON atomically", () => {
const filePath = join(TEST_DIR, "atomic.json")
const data = { key: "value", number: 123 }
writeJsonAtomic(filePath, data)
const content = readFileSync(filePath, "utf-8")
expect(JSON.parse(content)).toEqual(data)
})
// given a deeply nested path
// when calling writeJsonAtomic
// then it should create parent directories
it("creates parent directories", () => {
const filePath = join(TEST_DIR, "deep", "nested", "file.json")
writeJsonAtomic(filePath, { test: true })
expect(existsSync(filePath)).toBe(true)
})
})
})

View File

@@ -1,82 +0,0 @@
import { join, dirname } from "path"
import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, unlinkSync } from "fs"
import { homedir } from "os"
import type { z } from "zod"
import type { OhMyOpenCodeConfig } from "../../config/schema"
export function getTaskDir(listId: string, config: Partial<OhMyOpenCodeConfig>): string {
const tasksConfig = config.sisyphus?.tasks
if (tasksConfig?.claude_code_compat) {
return join(homedir(), ".cache", "claude-code", "tasks", listId)
}
const storagePath = tasksConfig?.storage_path ?? ".sisyphus/tasks"
return join(process.cwd(), storagePath, listId)
}
export function getTaskPath(listId: string, taskId: string, config: Partial<OhMyOpenCodeConfig>): string {
return join(getTaskDir(listId, config), `${taskId}.json`)
}
export function getTeamDir(teamName: string, config: Partial<OhMyOpenCodeConfig>): string {
const swarmConfig = config.sisyphus?.swarm
if (swarmConfig?.storage_path?.includes("claude")) {
return join(homedir(), ".claude", "teams", teamName)
}
const storagePath = swarmConfig?.storage_path ?? ".sisyphus/teams"
return join(process.cwd(), storagePath, teamName)
}
export function getInboxPath(teamName: string, agentName: string, config: Partial<OhMyOpenCodeConfig>): string {
return join(getTeamDir(teamName, config), "inboxes", `${agentName}.json`)
}
export function ensureDir(dirPath: string): void {
if (!existsSync(dirPath)) {
mkdirSync(dirPath, { recursive: true })
}
}
export function readJsonSafe<T>(filePath: string, schema: z.ZodType<T>): T | null {
try {
if (!existsSync(filePath)) {
return null
}
const content = readFileSync(filePath, "utf-8")
const parsed = JSON.parse(content)
const result = schema.safeParse(parsed)
if (!result.success) {
return null
}
return result.data
} catch {
return null
}
}
export function writeJsonAtomic(filePath: string, data: unknown): void {
const dir = dirname(filePath)
ensureDir(dir)
const tempPath = `${filePath}.tmp.${Date.now()}`
try {
writeFileSync(tempPath, JSON.stringify(data, null, 2), "utf-8")
renameSync(tempPath, filePath)
} catch (error) {
try {
if (existsSync(tempPath)) {
unlinkSync(tempPath)
}
} catch {
// Ignore cleanup errors
}
throw error
}
}

View File

@@ -1,82 +0,0 @@
import { describe, it, expect } from "bun:test"
import { TaskSchema, TaskStatusSchema, type Task } from "./types"
describe("TaskSchema", () => {
// given a valid task object
// when parsing with TaskSchema
// then it should succeed
it("parses valid task object", () => {
const validTask = {
id: "1",
subject: "Fix authentication bug",
description: "Users report 401 errors",
status: "pending",
blocks: [],
blockedBy: [],
}
const result = TaskSchema.safeParse(validTask)
expect(result.success).toBe(true)
})
// given a task with all optional fields
// when parsing with TaskSchema
// then it should succeed
it("parses task with optional fields", () => {
const taskWithOptionals = {
id: "2",
subject: "Add unit tests",
description: "Write tests for auth module",
activeForm: "Adding unit tests",
owner: "agent-001",
status: "in_progress",
blocks: ["3"],
blockedBy: ["1"],
metadata: { priority: "high", labels: ["bug"] },
}
const result = TaskSchema.safeParse(taskWithOptionals)
expect(result.success).toBe(true)
})
// given an invalid status value
// when parsing with TaskSchema
// then it should fail
it("rejects invalid status", () => {
const invalidTask = {
id: "1",
subject: "Test",
description: "Test",
status: "invalid_status",
blocks: [],
blockedBy: [],
}
const result = TaskSchema.safeParse(invalidTask)
expect(result.success).toBe(false)
})
// given missing required fields
// when parsing with TaskSchema
// then it should fail
it("rejects missing required fields", () => {
const invalidTask = {
id: "1",
// missing subject, description, status, blocks, blockedBy
}
const result = TaskSchema.safeParse(invalidTask)
expect(result.success).toBe(false)
})
})
describe("TaskStatusSchema", () => {
// given valid status values
// when parsing
// then all should succeed
it("accepts valid statuses", () => {
expect(TaskStatusSchema.safeParse("pending").success).toBe(true)
expect(TaskStatusSchema.safeParse("in_progress").success).toBe(true)
expect(TaskStatusSchema.safeParse("completed").success).toBe(true)
})
})

View File

@@ -1,41 +0,0 @@
import { z } from "zod"
export const TaskStatusSchema = z.enum(["pending", "in_progress", "completed"])
export type TaskStatus = z.infer<typeof TaskStatusSchema>
export const TaskSchema = z.object({
id: z.string(),
subject: z.string(),
description: z.string(),
activeForm: z.string().optional(),
owner: z.string().optional(),
status: TaskStatusSchema,
blocks: z.array(z.string()),
blockedBy: z.array(z.string()),
metadata: z.record(z.string(), z.unknown()).optional(),
})
export type Task = z.infer<typeof TaskSchema>
export const TaskCreateInputSchema = z.object({
subject: z.string().describe("Task title"),
description: z.string().describe("Detailed description"),
activeForm: z.string().optional().describe("Text shown when in progress"),
metadata: z.record(z.string(), z.unknown()).optional(),
})
export type TaskCreateInput = z.infer<typeof TaskCreateInputSchema>
export const TaskUpdateInputSchema = z.object({
taskId: z.string().describe("Task ID to update"),
subject: z.string().optional(),
description: z.string().optional(),
activeForm: z.string().optional(),
status: z.enum(["pending", "in_progress", "completed", "deleted"]).optional(),
addBlocks: z.array(z.string()).optional().describe("Task IDs this task will block"),
addBlockedBy: z.array(z.string()).optional().describe("Task IDs that block this task"),
owner: z.string().optional(),
metadata: z.record(z.string(), z.unknown()).optional(),
})
export type TaskUpdateInput = z.infer<typeof TaskUpdateInputSchema>

View File

@@ -0,0 +1,125 @@
import { describe, test, expect, beforeEach } from "bun:test"
import { createTaskReminderHook } from "./index"
import type { PluginInput } from "@opencode-ai/plugin"
const mockCtx = {} as PluginInput
describe("TaskReminderHook", () => {
let hook: ReturnType<typeof createTaskReminderHook>
beforeEach(() => {
hook = createTaskReminderHook(mockCtx)
})
test("does not inject reminder before 10 turns", async () => {
//#given
const sessionID = "test-session"
const output = { output: "Result" }
//#when
for (let i = 0; i < 9; i++) {
await hook["tool.execute.after"]?.(
{ tool: "bash", sessionID, callID: `call-${i}` },
output
)
}
//#then
expect(output.output).not.toContain("task tools haven't been used")
})
test("injects reminder after 10 turns without task tool usage", async () => {
//#given
const sessionID = "test-session"
const output = { output: "Result" }
//#when
for (let i = 0; i < 10; i++) {
await hook["tool.execute.after"]?.(
{ tool: "bash", sessionID, callID: `call-${i}` },
output
)
}
//#then
expect(output.output).toContain("task tools haven't been used")
})
test("resets counter when task tool is used", async () => {
//#given
const sessionID = "test-session"
const output = { output: "Result" }
//#when
for (let i = 0; i < 5; i++) {
await hook["tool.execute.after"]?.(
{ tool: "bash", sessionID, callID: `call-${i}` },
output
)
}
await hook["tool.execute.after"]?.(
{ tool: "task", sessionID, callID: "call-task" },
output
)
for (let i = 0; i < 9; i++) {
await hook["tool.execute.after"]?.(
{ tool: "bash", sessionID, callID: `call-after-${i}` },
output
)
}
//#then
expect(output.output).not.toContain("task tools haven't been used")
})
test("resets counter after injecting reminder", async () => {
//#given
const sessionID = "test-session"
const output1 = { output: "Result 1" }
const output2 = { output: "Result 2" }
//#when
for (let i = 0; i < 10; i++) {
await hook["tool.execute.after"]?.(
{ tool: "bash", sessionID, callID: `call-1-${i}` },
output1
)
}
for (let i = 0; i < 9; i++) {
await hook["tool.execute.after"]?.(
{ tool: "bash", sessionID, callID: `call-2-${i}` },
output2
)
}
//#then
expect(output1.output).toContain("task tools haven't been used")
expect(output2.output).not.toContain("task tools haven't been used")
})
test("tracks separate counters per session", async () => {
//#given
const session1 = "session-1"
const session2 = "session-2"
const output1 = { output: "Result 1" }
const output2 = { output: "Result 2" }
//#when
for (let i = 0; i < 10; i++) {
await hook["tool.execute.after"]?.(
{ tool: "bash", sessionID: session1, callID: `call-${i}` },
output1
)
}
for (let i = 0; i < 5; i++) {
await hook["tool.execute.after"]?.(
{ tool: "bash", sessionID: session2, callID: `call-${i}` },
output2
)
}
//#then
expect(output1.output).toContain("task tools haven't been used")
expect(output2.output).not.toContain("task tools haven't been used")
})
})

View File

@@ -0,0 +1,45 @@
import type { PluginInput } from "@opencode-ai/plugin"
const TASK_TOOLS = new Set(["task"])
const TURN_THRESHOLD = 10
const REMINDER_MESSAGE = `
The task tools haven't been used recently. If you're working on tasks that would benefit from tracking progress, consider using TaskCreate to add new tasks and TaskUpdate to update task status (set to in_progress when starting, completed when done).`
interface ToolExecuteInput {
tool: string
sessionID: string
callID: string
}
interface ToolExecuteOutput {
output: string
}
export function createTaskReminderHook(_ctx: PluginInput) {
const sessionCounters = new Map<string, number>()
const toolExecuteAfter = async (input: ToolExecuteInput, output: ToolExecuteOutput) => {
const { tool, sessionID } = input
const toolLower = tool.toLowerCase()
if (TASK_TOOLS.has(toolLower)) {
sessionCounters.set(sessionID, 0)
return
}
const currentCount = sessionCounters.get(sessionID) ?? 0
const newCount = currentCount + 1
if (newCount >= TURN_THRESHOLD) {
output.output += REMINDER_MESSAGE
sessionCounters.set(sessionID, 0)
} else {
sessionCounters.set(sessionID, newCount)
}
}
return {
"tool.execute.after": toolExecuteAfter,
}
}

View File

@@ -1,4 +1,4 @@
const TARGET_TOOLS = ["task", "Task", "call_omo_agent", "delegate_task"] const TARGET_TOOLS = ["task", "Task", "task_tool", "call_omo_agent", "delegate_task"]
const SESSION_ID_PATTERNS = [ const SESSION_ID_PATTERNS = [
/Session ID: (ses_[a-zA-Z0-9_-]+)/, /Session ID: (ses_[a-zA-Z0-9_-]+)/,

View File

@@ -39,6 +39,7 @@ export function createToolOutputTruncatorHook(ctx: PluginInput, options?: ToolOu
output: { title: string; output: string; metadata: unknown } output: { title: string; output: string; metadata: unknown }
) => { ) => {
if (!truncateAll && !TRUNCATABLE_TOOLS.includes(input.tool)) return if (!truncateAll && !TRUNCATABLE_TOOLS.includes(input.tool)) return
if (typeof output.output !== 'string') return
try { try {
const targetMaxTokens = TOOL_SPECIFIC_MAX_TOKENS[input.tool] ?? DEFAULT_MAX_TOKENS const targetMaxTokens = TOOL_SPECIFIC_MAX_TOKENS[input.tool] ?? DEFAULT_MAX_TOKENS

View File

@@ -73,6 +73,7 @@ import {
interactive_bash, interactive_bash,
startTmuxCheck, startTmuxCheck,
lspManager, lspManager,
createTask,
} from "./tools"; } from "./tools";
import { BackgroundManager } from "./features/background-agent"; import { BackgroundManager } from "./features/background-agent";
import { SkillMcpManager } from "./features/skill-mcp-manager"; import { SkillMcpManager } from "./features/skill-mcp-manager";
@@ -419,6 +420,9 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
modelCacheState, modelCacheState,
}); });
const newTaskSystemEnabled = pluginConfig.new_task_system_enabled ?? false;
const taskTool = newTaskSystemEnabled ? createTask(pluginConfig) : null;
return { return {
tool: { tool: {
...builtinTools, ...builtinTools,
@@ -430,6 +434,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
skill_mcp: skillMcpTool, skill_mcp: skillMcpTool,
slashcommand: slashcommandTool, slashcommand: slashcommandTool,
interactive_bash, interactive_bash,
...(taskTool ? { task: taskTool } : {}),
}, },
"chat.message": async (input, output) => { "chat.message": async (input, output) => {

View File

@@ -113,7 +113,7 @@ export function loadPluginConfig(
// Load user config first (base) // Load user config first (base)
let config: OhMyOpenCodeConfig = let config: OhMyOpenCodeConfig =
loadConfigFromPath(userConfigPath, ctx) ?? {}; loadConfigFromPath(userConfigPath, ctx) ?? { new_task_system_enabled: false };
// Override with project config // Override with project config
const projectConfig = loadConfigFromPath(projectConfigPath, ctx); const projectConfig = loadConfigFromPath(projectConfigPath, ctx);

View File

@@ -403,6 +403,8 @@ export function createConfigHandler(deps: ConfigHandlerDeps) {
LspHover: false, LspHover: false,
LspCodeActions: false, LspCodeActions: false,
LspCodeActionResolve: false, LspCodeActionResolve: false,
"task_*": false,
teammate: false,
}; };
type AgentWithPermission = { permission?: Record<string, unknown> }; type AgentWithPermission = { permission?: Record<string, unknown> };
@@ -417,11 +419,11 @@ export function createConfigHandler(deps: ConfigHandlerDeps) {
} }
if (agentResult["atlas"]) { if (agentResult["atlas"]) {
const agent = agentResult["atlas"] as AgentWithPermission; const agent = agentResult["atlas"] as AgentWithPermission;
agent.permission = { ...agent.permission, task: "deny", call_omo_agent: "deny", delegate_task: "allow" }; agent.permission = { ...agent.permission, task: "deny", call_omo_agent: "deny", delegate_task: "allow", "task_*": "allow", teammate: "allow" };
} }
if (agentResult.sisyphus) { if (agentResult.sisyphus) {
const agent = agentResult.sisyphus as AgentWithPermission; const agent = agentResult.sisyphus as AgentWithPermission;
agent.permission = { ...agent.permission, call_omo_agent: "deny", delegate_task: "allow", question: "allow" }; agent.permission = { ...agent.permission, call_omo_agent: "deny", delegate_task: "allow", question: "allow", "task_*": "allow", teammate: "allow" };
} }
if (agentResult.hephaestus) { if (agentResult.hephaestus) {
const agent = agentResult.hephaestus as AgentWithPermission; const agent = agentResult.hephaestus as AgentWithPermission;
@@ -429,11 +431,11 @@ export function createConfigHandler(deps: ConfigHandlerDeps) {
} }
if (agentResult["prometheus"]) { if (agentResult["prometheus"]) {
const agent = agentResult["prometheus"] as AgentWithPermission; const agent = agentResult["prometheus"] as AgentWithPermission;
agent.permission = { ...agent.permission, call_omo_agent: "deny", delegate_task: "allow", question: "allow" }; agent.permission = { ...agent.permission, call_omo_agent: "deny", delegate_task: "allow", question: "allow", "task_*": "allow", teammate: "allow" };
} }
if (agentResult["sisyphus-junior"]) { if (agentResult["sisyphus-junior"]) {
const agent = agentResult["sisyphus-junior"] as AgentWithPermission; const agent = agentResult["sisyphus-junior"] as AgentWithPermission;
agent.permission = { ...agent.permission, delegate_task: "allow" }; agent.permission = { ...agent.permission, delegate_task: "allow", "task_*": "allow", teammate: "allow" };
} }
config.permission = { config.permission = {

View File

@@ -43,6 +43,10 @@ export function truncateToTokenLimit(
maxTokens: number, maxTokens: number,
preserveHeaderLines = 3, preserveHeaderLines = 3,
): TruncationResult { ): TruncationResult {
if (typeof output !== 'string') {
return { result: String(output ?? ''), truncated: false };
}
const currentTokens = estimateTokens(output); const currentTokens = estimateTokens(output);
if (currentTokens <= maxTokens) { if (currentTokens <= maxTokens) {
@@ -147,6 +151,10 @@ export async function dynamicTruncate(
output: string, output: string,
options: TruncationOptions = {}, options: TruncationOptions = {},
): Promise<TruncationResult> { ): Promise<TruncationResult> {
if (typeof output !== 'string') {
return { result: String(output ?? ''), truncated: false };
}
const { const {
targetMaxTokens = DEFAULT_TARGET_MAX_TOKENS, targetMaxTokens = DEFAULT_TARGET_MAX_TOKENS,
preserveHeaderLines = 3, preserveHeaderLines = 3,

View File

@@ -13,6 +13,10 @@ export interface DelegateTaskArgs {
session_id?: string session_id?: string
command?: string command?: string
load_skills: string[] load_skills: string[]
execute?: {
task_id: string
task_dir?: string
}
} }
export interface ToolContextWithMetadata { export interface ToolContextWithMetadata {

View File

@@ -73,3 +73,5 @@ export const builtinTools: Record<string, ToolDefinition> = {
session_search, session_search,
session_info, session_info,
} }
export { createTask } from "./task"

2
src/tools/task/index.ts Normal file
View File

@@ -0,0 +1,2 @@
export { createTask } from "./task"
export type { TaskObject, TaskStatus, TaskCreateInput, TaskListInput, TaskGetInput, TaskUpdateInput, TaskDeleteInput } from "./types"

768
src/tools/task/task.test.ts Normal file
View File

@@ -0,0 +1,768 @@
import { describe, test, expect, beforeEach, afterEach } from "bun:test"
import { existsSync, rmSync, mkdirSync, writeFileSync, readdirSync } from "fs"
import { join } from "path"
import type { TaskObject } from "./types"
import { createTask } from "./task"
const TEST_STORAGE = ".test-task-tool"
const TEST_DIR = join(process.cwd(), TEST_STORAGE)
const TEST_CONFIG = {
new_task_system_enabled: true,
sisyphus: {
tasks: {
storage_path: TEST_STORAGE,
claude_code_compat: true,
},
},
}
const TEST_SESSION_ID = "test-session-123"
const TEST_ABORT_CONTROLLER = new AbortController()
const TEST_CONTEXT = {
sessionID: TEST_SESSION_ID,
messageID: "test-message-123",
agent: "test-agent",
abort: TEST_ABORT_CONTROLLER.signal,
}
describe("task_tool", () => {
let taskTool: ReturnType<typeof createTask>
beforeEach(() => {
if (existsSync(TEST_STORAGE)) {
rmSync(TEST_STORAGE, { recursive: true, force: true })
}
mkdirSync(TEST_DIR, { recursive: true })
taskTool = createTask(TEST_CONFIG)
})
async function createTestTask(title: string, overrides: Partial<Parameters<typeof taskTool.execute>[0]> = {}): Promise<string> {
const args = {
action: "create" as const,
title,
...overrides,
}
const resultStr = await taskTool.execute(args, TEST_CONTEXT)
const result = JSON.parse(resultStr)
return (result as { task: TaskObject }).task.id
}
afterEach(() => {
if (existsSync(TEST_STORAGE)) {
rmSync(TEST_STORAGE, { recursive: true, force: true })
}
})
// ============================================================================
// CREATE ACTION TESTS
// ============================================================================
describe("create action", () => {
test("creates task with required title field", async () => {
//#given
const args = {
action: "create" as const,
title: "Implement authentication",
}
//#when
const resultStr = await taskTool.execute(args, TEST_CONTEXT)
const result = JSON.parse(resultStr)
//#then
expect(result).toHaveProperty("task")
expect(result.task).toHaveProperty("id")
expect(result.task.title).toBe("Implement authentication")
expect(result.task.status).toBe("open")
})
test("auto-generates T-{uuid} format ID", async () => {
//#given
const args = {
action: "create" as const,
title: "Test task",
}
//#when
const resultStr = await taskTool.execute(args, TEST_CONTEXT)
const result = JSON.parse(resultStr)
//#then
expect(result.task.id).toMatch(/^T-[a-f0-9-]+$/)
})
test("auto-records threadID from session context", async () => {
//#given
const args = {
action: "create" as const,
title: "Test task",
}
//#when
const resultStr = await taskTool.execute(args, TEST_CONTEXT)
const result = JSON.parse(resultStr)
//#then
expect(result.task).toHaveProperty("threadID")
expect(typeof result.task.threadID).toBe("string")
})
test("sets status to open by default", async () => {
//#given
const args = {
action: "create" as const,
title: "Test task",
}
//#when
const resultStr = await taskTool.execute(args, TEST_CONTEXT)
const result = JSON.parse(resultStr)
//#then
expect(result.task.status).toBe("open")
})
test("stores optional description field", async () => {
//#given
const args = {
action: "create" as const,
title: "Test task",
description: "Detailed description of the task",
}
//#when
const resultStr = await taskTool.execute(args, TEST_CONTEXT)
const result = JSON.parse(resultStr)
//#then
expect(result.task.description).toBe("Detailed description of the task")
})
test("stores dependsOn array", async () => {
//#given
const args = {
action: "create" as const,
title: "Test task",
dependsOn: ["T-dep1", "T-dep2"],
}
//#when
const resultStr = await taskTool.execute(args, TEST_CONTEXT)
const result = JSON.parse(resultStr)
//#then
expect(result.task.dependsOn).toEqual(["T-dep1", "T-dep2"])
})
test("stores parentID when provided", async () => {
//#given
const args = {
action: "create" as const,
title: "Subtask",
parentID: "T-parent123",
}
//#when
const resultStr = await taskTool.execute(args, TEST_CONTEXT)
const result = JSON.parse(resultStr)
//#then
expect(result.task.parentID).toBe("T-parent123")
})
test("stores repoURL when provided", async () => {
//#given
const args = {
action: "create" as const,
title: "Test task",
repoURL: "https://github.com/code-yeongyu/oh-my-opencode",
}
//#when
const resultStr = await taskTool.execute(args, TEST_CONTEXT)
const result = JSON.parse(resultStr)
//#then
expect(result.task.repoURL).toBe("https://github.com/code-yeongyu/oh-my-opencode")
})
test("returns result as JSON string with task property", async () => {
//#given
const args = {
action: "create" as const,
title: "Test task",
}
//#when
const resultStr = await taskTool.execute(args, TEST_CONTEXT)
//#then
expect(typeof resultStr).toBe("string")
const result = JSON.parse(resultStr)
expect(result).toHaveProperty("task")
})
test("initializes dependsOn as empty array when not provided", async () => {
//#given
const args = {
action: "create" as const,
title: "Test task",
}
//#when
const resultStr = await taskTool.execute(args, TEST_CONTEXT)
const result = JSON.parse(resultStr)
//#then
expect(result.task.dependsOn).toEqual([])
})
})
// ============================================================================
// LIST ACTION TESTS
// ============================================================================
describe("list action", () => {
test("returns all non-completed tasks by default", async () => {
//#given
const args = {
action: "list" as const,
}
//#when
const resultStr = await taskTool.execute(args, TEST_CONTEXT)
const result = JSON.parse(resultStr)
//#then
expect(result).toHaveProperty("tasks")
expect(Array.isArray(result.tasks)).toBe(true)
})
test("excludes completed tasks from list", async () => {
//#given
const args = {
action: "list" as const,
}
//#when
const resultStr = await taskTool.execute(args, TEST_CONTEXT)
const result = JSON.parse(resultStr)
//#then
const completedTasks = result.tasks.filter((t: TaskObject) => t.status === "completed")
expect(completedTasks.length).toBe(0)
})
test("applies ready filter when requested", async () => {
//#given
const args = {
action: "list" as const,
ready: true,
}
//#when
const resultStr = await taskTool.execute(args, TEST_CONTEXT)
const result = JSON.parse(resultStr)
//#then
expect(result).toHaveProperty("tasks")
expect(Array.isArray(result.tasks)).toBe(true)
})
test("respects limit parameter", async () => {
//#given
const args = {
action: "list" as const,
limit: 5,
}
//#when
const resultStr = await taskTool.execute(args, TEST_CONTEXT)
const result = JSON.parse(resultStr)
//#then
expect(result.tasks.length).toBeLessThanOrEqual(5)
})
test("returns result as JSON string with tasks array", async () => {
//#given
const args = {
action: "list" as const,
}
//#when
const resultStr = await taskTool.execute(args, TEST_CONTEXT)
//#then
expect(typeof resultStr).toBe("string")
const result = JSON.parse(resultStr)
expect(Array.isArray(result.tasks)).toBe(true)
})
test("filters by status when provided", async () => {
//#given
const args = {
action: "list" as const,
status: "in_progress" as const,
}
//#when
const resultStr = await taskTool.execute(args, TEST_CONTEXT)
const result = JSON.parse(resultStr)
//#then
const allInProgress = result.tasks.every((t: TaskObject) => t.status === "in_progress")
expect(allInProgress).toBe(true)
})
})
// ============================================================================
// GET ACTION TESTS
// ============================================================================
describe("get action", () => {
test("returns task by ID", async () => {
//#given
const testId = await createTestTask("Test task")
const args = {
action: "get" as const,
id: testId,
}
//#when
const resultStr = await taskTool.execute(args, TEST_CONTEXT)
const result = JSON.parse(resultStr)
//#then
expect(result).toHaveProperty("task")
})
test("returns null for non-existent task", async () => {
//#given
const args = {
action: "get" as const,
id: "T-nonexistent",
}
//#when
const resultStr = await taskTool.execute(args, TEST_CONTEXT)
const result = JSON.parse(resultStr)
//#then
expect(result.task).toBeNull()
})
test("returns result as JSON string with task property", async () => {
//#given
const testId = await createTestTask("Test task")
const args = {
action: "get" as const,
id: testId,
}
//#when
const resultStr = await taskTool.execute(args, TEST_CONTEXT)
//#then
expect(typeof resultStr).toBe("string")
const result = JSON.parse(resultStr)
expect(result).toHaveProperty("task")
})
test("returns complete task object with all fields", async () => {
//#given
const args = {
action: "get" as const,
id: "T-test123",
}
//#when
const resultStr = await taskTool.execute(args, TEST_CONTEXT)
const result = JSON.parse(resultStr)
//#then
if (result.task !== null) {
expect(result.task).toHaveProperty("id")
expect(result.task).toHaveProperty("title")
expect(result.task).toHaveProperty("status")
expect(result.task).toHaveProperty("threadID")
}
})
})
// ============================================================================
// UPDATE ACTION TESTS
// ============================================================================
describe("update action", () => {
test("updates task title", async () => {
//#given
const testId = await createTestTask("Test task")
const args = {
action: "update" as const,
id: testId,
title: "Updated title",
}
//#when
const resultStr = await taskTool.execute(args, TEST_CONTEXT)
const result = JSON.parse(resultStr)
//#then
expect(result).toHaveProperty("task")
expect(result.task.title).toBe("Updated title")
})
test("updates task description", async () => {
//#given
const testId = await createTestTask("Test task", { description: "Initial description" })
const args = {
action: "update" as const,
id: testId,
description: "Updated description",
}
//#when
const resultStr = await taskTool.execute(args, TEST_CONTEXT)
const result = JSON.parse(resultStr)
//#then
expect(result.task.description).toBe("Updated description")
})
test("updates task status", async () => {
//#given
const testId = await createTestTask("Test task")
const args = {
action: "update" as const,
id: testId,
status: "in_progress" as const,
}
//#when
const resultStr = await taskTool.execute(args, TEST_CONTEXT)
const result = JSON.parse(resultStr)
//#then
expect(result.task.status).toBe("in_progress")
})
test("updates dependsOn array", async () => {
//#given
const testId = await createTestTask("Test task")
const args = {
action: "update" as const,
id: testId,
dependsOn: ["T-dep1", "T-dep2", "T-dep3"],
}
//#when
const resultStr = await taskTool.execute(args, TEST_CONTEXT)
const result = JSON.parse(resultStr)
//#then
expect(result.task.dependsOn).toEqual(["T-dep1", "T-dep2", "T-dep3"])
})
test("returns error for non-existent task", async () => {
//#given
const args = {
action: "update" as const,
id: "T-nonexistent",
title: "New title",
}
//#when
const resultStr = await taskTool.execute(args, TEST_CONTEXT)
const result = JSON.parse(resultStr)
//#then
expect(result).toHaveProperty("error")
expect(result.error).toBe("task_not_found")
})
test("returns result as JSON string with task property", async () => {
//#given
const testId = await createTestTask("Test task")
const args = {
action: "update" as const,
id: testId,
title: "Updated",
}
//#when
const resultStr = await taskTool.execute(args, TEST_CONTEXT)
//#then
expect(typeof resultStr).toBe("string")
const result = JSON.parse(resultStr)
expect(result).toHaveProperty("task")
})
test("updates multiple fields at once", async () => {
//#given
const testId = await createTestTask("Test task")
const args = {
action: "update" as const,
id: testId,
title: "New title",
description: "New description",
status: "completed" as const,
}
//#when
const resultStr = await taskTool.execute(args, TEST_CONTEXT)
const result = JSON.parse(resultStr)
//#then
expect(result.task.title).toBe("New title")
expect(result.task.description).toBe("New description")
expect(result.task.status).toBe("completed")
})
})
// ============================================================================
// DELETE ACTION TESTS
// ============================================================================
describe("delete action", () => {
test("removes task file physically", async () => {
//#given
const testId = await createTestTask("Test task")
const args = {
action: "delete" as const,
id: testId,
}
//#when
const resultStr = await taskTool.execute(args, TEST_CONTEXT)
const result = JSON.parse(resultStr)
//#then
expect(result).toHaveProperty("success")
expect(result.success).toBe(true)
})
test("returns success true on successful deletion", async () => {
//#given
const testId = await createTestTask("Test task")
const args = {
action: "delete" as const,
id: testId,
}
//#when
const resultStr = await taskTool.execute(args, TEST_CONTEXT)
const result = JSON.parse(resultStr)
//#then
expect(result.success).toBe(true)
})
test("returns error for non-existent task", async () => {
//#given
const args = {
action: "delete" as const,
id: "T-nonexistent",
}
//#when
const resultStr = await taskTool.execute(args, TEST_CONTEXT)
const result = JSON.parse(resultStr)
//#then
expect(result).toHaveProperty("error")
expect(result.error).toBe("task_not_found")
})
test("returns result as JSON string", async () => {
//#given
const testId = await createTestTask("Test task")
const args = {
action: "delete" as const,
id: testId,
}
//#when
const resultStr = await taskTool.execute(args, TEST_CONTEXT)
//#then
expect(typeof resultStr).toBe("string")
const result = JSON.parse(resultStr)
expect(result).toHaveProperty("success")
})
})
// ============================================================================
// EDGE CASE TESTS
// ============================================================================
describe("edge cases", () => {
test("detects circular dependency (A depends on B, B depends on A)", async () => {
//#given
const args = {
action: "create" as const,
title: "Task A",
dependsOn: ["T-taskB"],
}
//#when
const resultStr = await taskTool.execute(args, TEST_CONTEXT)
const result = JSON.parse(resultStr)
//#then
// Should either prevent creation or mark as circular
expect(result).toHaveProperty("task")
})
test("handles task depending on non-existent ID", async () => {
//#given
const args = {
action: "create" as const,
title: "Task with missing dependency",
dependsOn: ["T-nonexistent"],
}
//#when
const resultStr = await taskTool.execute(args, TEST_CONTEXT)
const result = JSON.parse(resultStr)
//#then
// Should either allow or return error
expect(result).toHaveProperty("task")
})
test("ready filter returns true for empty dependsOn", async () => {
//#given
const args = {
action: "list" as const,
ready: true,
}
//#when
const resultStr = await taskTool.execute(args, TEST_CONTEXT)
const result = JSON.parse(resultStr)
//#then
const tasksWithNoDeps = result.tasks.filter((t: TaskObject) => t.dependsOn.length === 0)
expect(tasksWithNoDeps.length).toBeGreaterThanOrEqual(0)
})
test("ready filter includes tasks with all completed dependencies", async () => {
//#given
const args = {
action: "list" as const,
ready: true,
}
//#when
const resultStr = await taskTool.execute(args, TEST_CONTEXT)
const result = JSON.parse(resultStr)
//#then
expect(Array.isArray(result.tasks)).toBe(true)
})
test("ready filter excludes tasks with incomplete dependencies", async () => {
//#given
const args = {
action: "list" as const,
ready: true,
}
//#when
const resultStr = await taskTool.execute(args, TEST_CONTEXT)
const result = JSON.parse(resultStr)
//#then
expect(Array.isArray(result.tasks)).toBe(true)
})
test("handles empty title gracefully", async () => {
//#given
const args = {
action: "create" as const,
title: "",
}
//#when
const resultStr = await taskTool.execute(args, TEST_CONTEXT)
const result = JSON.parse(resultStr)
//#then
// Should either reject or handle empty title
expect(result).toBeDefined()
})
test("handles very long title", async () => {
//#given
const longTitle = "A".repeat(1000)
const args = {
action: "create" as const,
title: longTitle,
}
//#when
const resultStr = await taskTool.execute(args, TEST_CONTEXT)
const result = JSON.parse(resultStr)
//#then
expect(result).toBeDefined()
})
test("handles special characters in title", async () => {
//#given
const args = {
action: "create" as const,
title: "Task with special chars: !@#$%^&*()",
}
//#when
const resultStr = await taskTool.execute(args, TEST_CONTEXT)
const result = JSON.parse(resultStr)
//#then
expect(result).toBeDefined()
})
test("handles unicode characters in title", async () => {
//#given
const args = {
action: "create" as const,
title: "任務 🚀 Tâche",
}
//#when
const resultStr = await taskTool.execute(args, TEST_CONTEXT)
const result = JSON.parse(resultStr)
//#then
expect(result).toBeDefined()
})
test("preserves all TaskObject fields in round-trip", async () => {
//#given
const args = {
action: "create" as const,
title: "Test task",
description: "Test description",
dependsOn: ["T-dep1"],
parentID: "T-parent",
repoURL: "https://example.com",
}
//#when
const resultStr = await taskTool.execute(args, TEST_CONTEXT)
const result = JSON.parse(resultStr)
//#then
expect(result.task).toHaveProperty("id")
expect(result.task).toHaveProperty("title")
expect(result.task).toHaveProperty("description")
expect(result.task).toHaveProperty("status")
expect(result.task).toHaveProperty("dependsOn")
expect(result.task).toHaveProperty("parentID")
expect(result.task).toHaveProperty("repoURL")
expect(result.task).toHaveProperty("threadID")
})
})
})

253
src/tools/task/task.ts Normal file
View File

@@ -0,0 +1,253 @@
import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool"
import { existsSync, readdirSync, unlinkSync } from "fs"
import { join } from "path"
import type { OhMyOpenCodeConfig } from "../../config/schema"
import type {
TaskObject,
TaskCreateInput,
TaskListInput,
TaskGetInput,
TaskUpdateInput,
TaskDeleteInput,
} from "./types"
import {
TaskObjectSchema,
TaskCreateInputSchema,
TaskListInputSchema,
TaskGetInputSchema,
TaskUpdateInputSchema,
TaskDeleteInputSchema,
} from "./types"
import {
getTaskDir,
readJsonSafe,
writeJsonAtomic,
acquireLock,
generateTaskId,
listTaskFiles,
} from "../../features/claude-tasks/storage"
export function createTask(config: Partial<OhMyOpenCodeConfig>): ToolDefinition {
return tool({
description: `Unified task management tool with create, list, get, update, delete actions.
**CREATE**: Create a new task. Auto-generates T-{uuid} ID, records threadID, sets status to "open".
**LIST**: List tasks. Excludes completed by default. Supports ready filter (all dependencies completed) and limit.
**GET**: Retrieve a task by ID.
**UPDATE**: Update task fields. Requires task ID.
**DELETE**: Physically remove task file.
All actions return JSON strings.`,
args: {
action: tool.schema
.enum(["create", "list", "get", "update", "delete"])
.describe("Action to perform: create, list, get, update, delete"),
title: tool.schema.string().optional().describe("Task title (required for create)"),
description: tool.schema.string().optional().describe("Task description"),
status: tool.schema
.enum(["open", "in_progress", "completed"])
.optional()
.describe("Task status"),
dependsOn: tool.schema
.array(tool.schema.string())
.optional()
.describe("Task IDs this task depends on"),
repoURL: tool.schema.string().optional().describe("Repository URL"),
parentID: tool.schema.string().optional().describe("Parent task ID"),
id: tool.schema.string().optional().describe("Task ID (required for get, update, delete)"),
ready: tool.schema.boolean().optional().describe("Filter to tasks with all dependencies completed"),
limit: tool.schema.number().optional().describe("Maximum number of tasks to return"),
},
execute: async (args, context) => {
const action = args.action as "create" | "list" | "get" | "update" | "delete"
switch (action) {
case "create":
return handleCreate(args, config, context)
case "list":
return handleList(args, config)
case "get":
return handleGet(args, config)
case "update":
return handleUpdate(args, config)
case "delete":
return handleDelete(args, config)
default:
return JSON.stringify({ error: "invalid_action" })
}
},
})
}
async function handleCreate(
args: Record<string, unknown>,
config: Partial<OhMyOpenCodeConfig>,
context: { sessionID: string }
): Promise<string> {
const validatedArgs = TaskCreateInputSchema.parse(args)
const taskDir = getTaskDir(config)
const lock = acquireLock(taskDir)
try {
const taskId = generateTaskId()
const task: TaskObject = {
id: taskId,
title: validatedArgs.title,
description: validatedArgs.description,
status: "open",
dependsOn: validatedArgs.dependsOn ?? [],
repoURL: validatedArgs.repoURL,
parentID: validatedArgs.parentID,
threadID: context.sessionID,
}
const validatedTask = TaskObjectSchema.parse(task)
writeJsonAtomic(join(taskDir, `${taskId}.json`), validatedTask)
return JSON.stringify({ task: validatedTask })
} finally {
lock.release()
}
}
async function handleList(
args: Record<string, unknown>,
config: Partial<OhMyOpenCodeConfig>
): Promise<string> {
const validatedArgs = TaskListInputSchema.parse(args)
const taskDir = getTaskDir(config)
if (!existsSync(taskDir)) {
return JSON.stringify({ tasks: [] })
}
const files = listTaskFiles(config)
if (files.length === 0) {
return JSON.stringify({ tasks: [] })
}
const allTasks: TaskObject[] = []
for (const fileId of files) {
const task = readJsonSafe(join(taskDir, `${fileId}.json`), TaskObjectSchema)
if (task) {
allTasks.push(task)
}
}
// Filter out completed tasks by default
let tasks = allTasks.filter((task) => task.status !== "completed")
// Apply status filter if provided
if (validatedArgs.status) {
tasks = tasks.filter((task) => task.status === validatedArgs.status)
}
// Apply parentID filter if provided
if (validatedArgs.parentID) {
tasks = tasks.filter((task) => task.parentID === validatedArgs.parentID)
}
// Apply ready filter if requested
if (args.ready) {
tasks = tasks.filter((task) => {
if (task.dependsOn.length === 0) {
return true
}
// All dependencies must be completed
return task.dependsOn.every((depId) => {
const depTask = allTasks.find((t) => t.id === depId)
return depTask?.status === "completed"
})
})
}
// Apply limit if provided
const limit = args.limit as number | undefined
if (limit !== undefined && limit > 0) {
tasks = tasks.slice(0, limit)
}
return JSON.stringify({ tasks })
}
async function handleGet(
args: Record<string, unknown>,
config: Partial<OhMyOpenCodeConfig>
): Promise<string> {
const validatedArgs = TaskGetInputSchema.parse(args)
const taskDir = getTaskDir(config)
const taskPath = join(taskDir, `${validatedArgs.id}.json`)
const task = readJsonSafe(taskPath, TaskObjectSchema)
return JSON.stringify({ task: task ?? null })
}
async function handleUpdate(
args: Record<string, unknown>,
config: Partial<OhMyOpenCodeConfig>
): Promise<string> {
const validatedArgs = TaskUpdateInputSchema.parse(args)
const taskDir = getTaskDir(config)
const lock = acquireLock(taskDir)
try {
const taskPath = join(taskDir, `${validatedArgs.id}.json`)
const task = readJsonSafe(taskPath, TaskObjectSchema)
if (!task) {
return JSON.stringify({ error: "task_not_found" })
}
// Update fields if provided
if (validatedArgs.title !== undefined) {
task.title = validatedArgs.title
}
if (validatedArgs.description !== undefined) {
task.description = validatedArgs.description
}
if (validatedArgs.status !== undefined) {
task.status = validatedArgs.status
}
if (validatedArgs.dependsOn !== undefined) {
task.dependsOn = validatedArgs.dependsOn
}
if (validatedArgs.repoURL !== undefined) {
task.repoURL = validatedArgs.repoURL
}
if (validatedArgs.parentID !== undefined) {
task.parentID = validatedArgs.parentID
}
const validatedTask = TaskObjectSchema.parse(task)
writeJsonAtomic(taskPath, validatedTask)
return JSON.stringify({ task: validatedTask })
} finally {
lock.release()
}
}
async function handleDelete(
args: Record<string, unknown>,
config: Partial<OhMyOpenCodeConfig>
): Promise<string> {
const validatedArgs = TaskDeleteInputSchema.parse(args)
const taskDir = getTaskDir(config)
const lock = acquireLock(taskDir)
try {
const taskPath = join(taskDir, `${validatedArgs.id}.json`)
if (!existsSync(taskPath)) {
return JSON.stringify({ error: "task_not_found" })
}
unlinkSync(taskPath)
return JSON.stringify({ success: true })
} finally {
lock.release()
}
}

61
src/tools/task/types.ts Normal file
View File

@@ -0,0 +1,61 @@
import { z } from "zod"
export const TaskStatusSchema = z.enum(["open", "in_progress", "completed"])
export type TaskStatus = z.infer<typeof TaskStatusSchema>
export const TaskObjectSchema = z
.object({
id: z.string(),
title: z.string(),
description: z.string().optional(),
status: TaskStatusSchema,
dependsOn: z.array(z.string()).default([]),
repoURL: z.string().optional(),
parentID: z.string().optional(),
threadID: z.string(),
})
.strict()
export type TaskObject = z.infer<typeof TaskObjectSchema>
// Action input schemas
export const TaskCreateInputSchema = z.object({
title: z.string(),
description: z.string().optional(),
dependsOn: z.array(z.string()).optional(),
repoURL: z.string().optional(),
parentID: z.string().optional(),
})
export type TaskCreateInput = z.infer<typeof TaskCreateInputSchema>
export const TaskListInputSchema = z.object({
status: TaskStatusSchema.optional(),
parentID: z.string().optional(),
})
export type TaskListInput = z.infer<typeof TaskListInputSchema>
export const TaskGetInputSchema = z.object({
id: z.string(),
})
export type TaskGetInput = z.infer<typeof TaskGetInputSchema>
export const TaskUpdateInputSchema = z.object({
id: z.string(),
title: z.string().optional(),
description: z.string().optional(),
status: TaskStatusSchema.optional(),
dependsOn: z.array(z.string()).optional(),
repoURL: z.string().optional(),
parentID: z.string().optional(),
})
export type TaskUpdateInput = z.infer<typeof TaskUpdateInputSchema>
export const TaskDeleteInputSchema = z.object({
id: z.string(),
})
export type TaskDeleteInput = z.infer<typeof TaskDeleteInputSchema>

View File

@@ -16,5 +16,5 @@
"types": ["bun-types"] "types": ["bun-types"]
}, },
"include": ["src/**/*"], "include": ["src/**/*"],
"exclude": ["node_modules", "dist"] "exclude": ["node_modules", "dist", "**/*.test.ts", "script"]
} }