feat(sisyphus): add foundation schemas for tasks and swarm (Wave 1)

- Add SisyphusTasksConfig and SisyphusSwarmConfig to schema.ts
- Create Task JSON schema with Zod validation
- Create Mailbox IPC protocol message schemas
- Add storage utilities with Claude Code path compatibility
- 25 tests passing
This commit is contained in:
justsisyphus
2026-01-27 11:46:26 +09:00
parent de439edc22
commit bd18f231f5
8 changed files with 719 additions and 0 deletions

View File

@@ -2808,6 +2808,50 @@
"minimum": 20
}
}
},
"sisyphus": {
"type": "object",
"properties": {
"tasks": {
"type": "object",
"properties": {
"enabled": {
"default": false,
"type": "boolean"
},
"storage_path": {
"default": ".sisyphus/tasks",
"type": "string"
},
"claude_code_compat": {
"default": false,
"type": "boolean"
}
}
},
"swarm": {
"type": "object",
"properties": {
"enabled": {
"default": false,
"type": "boolean"
},
"storage_path": {
"default": ".sisyphus/teams",
"type": "string"
},
"ui_mode": {
"default": "toast",
"type": "string",
"enum": [
"toast",
"tmux",
"both"
]
}
}
}
}
}
}
}

View File

@@ -339,6 +339,29 @@ export const TmuxConfigSchema = z.object({
main_pane_min_width: z.number().min(40).default(120),
agent_pane_min_width: z.number().min(20).default(40),
})
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: z.string().default(".sisyphus/tasks"),
/** Enable Claude Code path compatibility mode */
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({
tasks: SisyphusTasksConfigSchema.optional(),
swarm: SisyphusSwarmConfigSchema.optional(),
})
export const OhMyOpenCodeConfigSchema = z.object({
$schema: z.string().optional(),
disabled_mcps: z.array(AnyMcpNameSchema).optional(),
@@ -360,6 +383,7 @@ export const OhMyOpenCodeConfigSchema = z.object({
git_master: GitMasterConfigSchema.optional(),
browser_automation_engine: BrowserAutomationConfigSchema.optional(),
tmux: TmuxConfigSchema.optional(),
sisyphus: SisyphusConfigSchema.optional(),
})
export type OhMyOpenCodeConfig = z.infer<typeof OhMyOpenCodeConfigSchema>
@@ -386,5 +410,8 @@ export type BrowserAutomationProvider = z.infer<typeof BrowserAutomationProvider
export type BrowserAutomationConfig = z.infer<typeof BrowserAutomationConfigSchema>
export type TmuxConfig = z.infer<typeof TmuxConfigSchema>
export type TmuxLayout = z.infer<typeof TmuxLayoutSchema>
export type SisyphusTasksConfig = z.infer<typeof SisyphusTasksConfigSchema>
export type SisyphusSwarmConfig = z.infer<typeof SisyphusSwarmConfigSchema>
export type SisyphusConfig = z.infer<typeof SisyphusConfigSchema>
export { AnyMcpNameSchema, type AnyMcpName, McpNameSchema, type McpName } from "../mcp/types"

View File

@@ -0,0 +1,112 @@
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

@@ -0,0 +1,153 @@
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

@@ -0,0 +1,178 @@
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

@@ -0,0 +1,82 @@
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

@@ -0,0 +1,82 @@
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

@@ -0,0 +1,41 @@
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>