diff --git a/assets/oh-my-opencode.schema.json b/assets/oh-my-opencode.schema.json index 0d20b75b5..27134431b 100644 --- a/assets/oh-my-opencode.schema.json +++ b/assets/oh-my-opencode.schema.json @@ -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" + ] + } + } + } + } } } } \ No newline at end of file diff --git a/src/config/schema.ts b/src/config/schema.ts index 08e5a1f2e..3c9ab7b5e 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -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 @@ -386,5 +410,8 @@ export type BrowserAutomationProvider = z.infer export type TmuxConfig = z.infer export type TmuxLayout = z.infer +export type SisyphusTasksConfig = z.infer +export type SisyphusSwarmConfig = z.infer +export type SisyphusConfig = z.infer export { AnyMcpNameSchema, type AnyMcpName, McpNameSchema, type McpName } from "../mcp/types" diff --git a/src/features/sisyphus-swarm/mailbox/types.test.ts b/src/features/sisyphus-swarm/mailbox/types.test.ts new file mode 100644 index 000000000..a3d426d90 --- /dev/null +++ b/src/features/sisyphus-swarm/mailbox/types.test.ts @@ -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) + }) +}) diff --git a/src/features/sisyphus-swarm/mailbox/types.ts b/src/features/sisyphus-swarm/mailbox/types.ts new file mode 100644 index 000000000..ae222818b --- /dev/null +++ b/src/features/sisyphus-swarm/mailbox/types.ts @@ -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 + +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 + +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 + +export const ShutdownRequestSchema = z.object({ + type: z.literal("shutdown_request"), +}) + +export type ShutdownRequest = z.infer + +export const ShutdownApprovedSchema = z.object({ + type: z.literal("shutdown_approved"), +}) + +export type ShutdownApproved = z.infer + +export const ShutdownRejectedSchema = z.object({ + type: z.literal("shutdown_rejected"), + reason: z.string().optional(), +}) + +export type ShutdownRejected = z.infer + +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 + +export const TaskCompletedSchema = z.object({ + type: z.literal("task_completed"), + taskId: z.string(), + agentId: z.string(), + timestamp: z.number(), +}) + +export type TaskCompleted = z.infer + +export const IdleNotificationSchema = z.object({ + type: z.literal("idle_notification"), +}) + +export type IdleNotification = z.infer + +export const JoinRequestSchema = z.object({ + type: z.literal("join_request"), + agentName: z.string(), + sessionId: z.string(), +}) + +export type JoinRequest = z.infer + +export const JoinApprovedSchema = z.object({ + type: z.literal("join_approved"), + agentName: z.string(), + teamName: z.string(), +}) + +export type JoinApproved = z.infer + +export const JoinRejectedSchema = z.object({ + type: z.literal("join_rejected"), + reason: z.string().optional(), +}) + +export type JoinRejected = z.infer + +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 + +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 + +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 + +export const TeamPermissionUpdateSchema = z.object({ + type: z.literal("team_permission_update"), + permissions: z.record(z.string(), z.unknown()), +}) + +export type TeamPermissionUpdate = z.infer + +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 diff --git a/src/features/sisyphus-tasks/storage.test.ts b/src/features/sisyphus-tasks/storage.test.ts new file mode 100644 index 000000000..888b35f8d --- /dev/null +++ b/src/features/sisyphus-tasks/storage.test.ts @@ -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) + }) + }) +}) diff --git a/src/features/sisyphus-tasks/storage.ts b/src/features/sisyphus-tasks/storage.ts new file mode 100644 index 000000000..64c5f01dd --- /dev/null +++ b/src/features/sisyphus-tasks/storage.ts @@ -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): 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): string { + return join(getTaskDir(listId, config), `${taskId}.json`) +} + +export function getTeamDir(teamName: string, config: Partial): 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): 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(filePath: string, schema: z.ZodType): 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 + } +} diff --git a/src/features/sisyphus-tasks/types.test.ts b/src/features/sisyphus-tasks/types.test.ts new file mode 100644 index 000000000..61ac4f562 --- /dev/null +++ b/src/features/sisyphus-tasks/types.test.ts @@ -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) + }) +}) diff --git a/src/features/sisyphus-tasks/types.ts b/src/features/sisyphus-tasks/types.ts new file mode 100644 index 000000000..b6349aee8 --- /dev/null +++ b/src/features/sisyphus-tasks/types.ts @@ -0,0 +1,41 @@ +import { z } from "zod" + +export const TaskStatusSchema = z.enum(["pending", "in_progress", "completed"]) +export type TaskStatus = z.infer + +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 + +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 + +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